diff --git a/.env b/.env new file mode 100644 index 0000000..6148558 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +SECRET_KEY=tYmcw4icw9qlVU2wToq3Szpli6TLhKqsHFCh616oY3ZnbmUbKh1ehjZzcZZwtHKqcgE +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1,web + +DATABASE_URL=postgresql://shooter:shooter_secret@db:5432/shooter_hub + +# External IDP credentials (configure in Django admin or here) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +APPLE_CLIENT_ID= +APPLE_CLIENT_SECRET= diff --git a/.env.example b/.env.example index c6509f7..4155604 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,12 @@ -# Copy this file to .env and fill in real values. -# .env is gitignored — never commit secrets. +SECRET_KEY=change-me-in-production +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1,web -SECRET_KEY=change-me-to-a-long-random-string +DATABASE_URL=postgresql://shooter:shooter_secret@db:5432/shooter_hub -DB_PASSWORD=change-me-db-password - -# Google OAuth — https://console.developers.google.com/ +# External IDP credentials (configure in Django admin or here) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -# GitHub OAuth — https://github.com/settings/developers -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= - -# Email confirmation for local accounts. -# Set to "true" to require users to click a confirmation link before logging in. -# When "false" (default), accounts are activated immediately. -# The confirmation URL is always printed to Docker logs regardless of this setting. -EMAIL_CONFIRMATION_REQUIRED=false +APPLE_CLIENT_ID= +APPLE_CLIENT_SECRET= diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f8ecaf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Running the Project + +```bash +docker compose up # Start all services (db, backend, frontend) +docker compose up -d # Start in detached mode +docker compose down # Stop all services +docker compose build # Rebuild images after Dockerfile/requirements changes +``` + +Services: +- Django API: `http://localhost:8000` +- Frontend (Nginx): `http://localhost:5173` + +Default dev admin: `admin@shooterhub.local` / `changeme` + +## Django Commands (inside container or with venv) + +```bash +python manage.py migrate +python manage.py makemigrations +python manage.py compilemessages # Required after editing .po translation files +python manage.py shell +python manage.py create_default_admin +``` + +No test suite or linter is configured yet. + +## Architecture Overview + +**Backend**: Django 4.2 + Django REST Framework, PostgreSQL 16, JWT auth (simplejwt) + OAuth2 (allauth for Google/Apple). + +**Frontend**: Vanilla JS + Bootstrap 5.3 served by Nginx. No build step — files are served as-is. `frontend/js/api.js` is the central API client handling JWT refresh logic. + +### Django Apps (`apps/`) + +| App | Responsibility | +|-----|----------------| +| `users` | Custom User model (email-based auth), language preference, avatar | +| `gears` | Firearm & equipment catalog + per-user inventory + reloading (recipes, ammo batches) | +| `tools` | Chronograph data ingestion, shot group analysis, PDF/chart generation | +| `photos` | Binary image storage in PostgreSQL, bullet-hole annotation, ballistic overlays | + +### Key Architectural Patterns + +- **Gear catalog moderation**: Items have PENDING/VERIFIED/REJECTED status. Verified items are shared community-wide; unverified are private. `GearCatalogMixin` in `gears/views.py` enforces this. +- **Ownership permissions**: Custom permission classes (`IsOwnerOrUnclaimed`, `IsAdminOrReadOnly`) in each app's `permissions.py`. +- **Photo storage**: Images are stored as raw bytes in a PostgreSQL `bytea` field (`Photo.data`) — no filesystem or S3 involved. +- **Chronograph analysis pipeline**: `apps/tools/analyzer/` contains independent modules — `parser.py` (CSV→DataFrame), `grouper.py` (shot clustering), `stats.py` (velocity stats), `charts.py` (matplotlib), `pdf_report.py` (fpdf2). +- **Anonymous sessions**: `ChronographAnalysis` can be unclaimed (no user FK), later claimed after login. +- **Nested routing**: Shot groups and shots are nested under chronograph analyses in the URL structure. +- **i18n**: 4 languages (en, fr, de, es). Django backend uses `.po`/`.mo` files; frontend uses `frontend/js/i18n.js`. + +### URL Structure + +All API routes are under `/api/`: + +``` +/api/auth/token/refresh/ +/api/auth/social/google/ +/api/auth/social/apple/ +/api/users/profile/ +/api/users/admin/ +/api/gears/firearms/ /api/gears/scopes/ /api/gears/suppressors/ ... +/api/gears/ammo/ +/api/gears/components/primers|brass|bullets|powders/ +/api/inventory/ +/api/rigs/ +/api/reloading/recipes/ /api/reloading/batches/ +/api/tools/chronograph/ +/api/tools/chronograph/{id}/groups/ +/api/tools/chronograph/{id}/groups/{gid}/shots/ +/api/photos/upload/ +/api/photos/{pk}/data/ +/api/photos/group-photos/ +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79689bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + gcc \ + gettext \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . diff --git a/ShooterHub Data Model.png b/ShooterHub Data Model.png new file mode 100644 index 0000000..070e463 Binary files /dev/null and b/ShooterHub Data Model.png differ diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/calibers/__init__.py b/apps/calibers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/calibers/admin.py b/apps/calibers/admin.py new file mode 100644 index 0000000..44a3b80 --- /dev/null +++ b/apps/calibers/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import Caliber + + +@admin.register(Caliber) +class CaliberAdmin(admin.ModelAdmin): + list_display = ['name', 'short_name', 'status', 'submitted_by'] + list_filter = ['status'] + search_fields = ['name', 'short_name'] + readonly_fields = ['submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at'] diff --git a/apps/calibers/apps.py b/apps/calibers/apps.py new file mode 100644 index 0000000..6bfd5db --- /dev/null +++ b/apps/calibers/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CalibersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.calibers' diff --git a/apps/calibers/migrations/0001_initial.py b/apps/calibers/migrations/0001_initial.py new file mode 100644 index 0000000..a948049 --- /dev/null +++ b/apps/calibers/migrations/0001_initial.py @@ -0,0 +1,60 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Caliber', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='name')), + ('short_name', models.CharField(blank=True, max_length=50, verbose_name='short name')), + ('case_length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='case length (mm)')), + ('overall_length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='overall length (mm)')), + ('bullet_diameter_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='bullet diameter (mm)')), + ('case_head_diameter_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='case head diameter (mm)')), + ('rim_diameter_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='rim diameter (mm)')), + ('max_pressure_mpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='max pressure (MPa)')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('status', models.CharField( + choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], + default='PENDING', + max_length=10, + verbose_name='status', + )), + ('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='reviewed at')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('reviewed_by', models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='reviewed_calibers', + to=settings.AUTH_USER_MODEL, + verbose_name='reviewed by', + )), + ('submitted_by', models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='submitted_calibers', + to=settings.AUTH_USER_MODEL, + verbose_name='submitted by', + )), + ], + options={ + 'verbose_name': 'caliber', + 'verbose_name_plural': 'calibers', + 'ordering': ['name'], + }, + ), + ] diff --git a/apps/calibers/migrations/__init__.py b/apps/calibers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/calibers/models.py b/apps/calibers/models.py new file mode 100644 index 0000000..ad156b4 --- /dev/null +++ b/apps/calibers/models.py @@ -0,0 +1,76 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class CaliberStatus(models.TextChoices): + PENDING = 'PENDING', _('Pending Verification') + VERIFIED = 'VERIFIED', _('Verified') + REJECTED = 'REJECTED', _('Rejected') + + +class Caliber(models.Model): + name = models.CharField(_('name'), max_length=100, unique=True) + short_name = models.CharField(_('short name'), max_length=50, blank=True) + + # CIP standard dimensions + case_length_mm = models.DecimalField( + _('case length (mm)'), max_digits=5, decimal_places=2, null=True, blank=True + ) + overall_length_mm = models.DecimalField( + _('overall length (mm)'), max_digits=5, decimal_places=2, null=True, blank=True + ) + bullet_diameter_mm = models.DecimalField( + _('bullet diameter (mm)'), max_digits=5, decimal_places=2, null=True, blank=True + ) + case_head_diameter_mm = models.DecimalField( + _('case head diameter (mm)'), max_digits=5, decimal_places=2, null=True, blank=True + ) + rim_diameter_mm = models.DecimalField( + _('rim diameter (mm)'), max_digits=5, decimal_places=2, null=True, blank=True + ) + max_pressure_mpa = models.DecimalField( + _('max pressure (MPa)'), max_digits=6, decimal_places=1, null=True, blank=True + ) + notes = models.TextField(_('notes'), blank=True) + + # Moderation + status = models.CharField( + _('status'), max_length=10, choices=CaliberStatus.choices, default=CaliberStatus.PENDING + ) + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='submitted_calibers', + verbose_name=_('submitted by'), + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='reviewed_calibers', + verbose_name=_('reviewed by'), + ) + reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True) + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + updated_at = models.DateTimeField(_('updated at'), auto_now=True) + + class Meta: + verbose_name = _('caliber') + verbose_name_plural = _('calibers') + ordering = ['name'] + + def __str__(self): + if self.short_name: + return f"{self.name} ({self.short_name})" + return self.name + + def verify(self, reviewed_by): + self.status = CaliberStatus.VERIFIED + self.reviewed_by = reviewed_by + self.reviewed_at = timezone.now() + self.save(update_fields=['status', 'reviewed_by', 'reviewed_at']) + + def reject(self, reviewed_by): + self.status = CaliberStatus.REJECTED + self.reviewed_by = reviewed_by + self.reviewed_at = timezone.now() + self.save(update_fields=['status', 'reviewed_by', 'reviewed_at']) diff --git a/apps/calibers/serializers.py b/apps/calibers/serializers.py new file mode 100644 index 0000000..0fa739c --- /dev/null +++ b/apps/calibers/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from .models import Caliber + + +class CaliberSerializer(serializers.ModelSerializer): + class Meta: + model = Caliber + fields = [ + 'id', 'name', 'short_name', + 'case_length_mm', 'overall_length_mm', 'bullet_diameter_mm', + 'case_head_diameter_mm', 'rim_diameter_mm', 'max_pressure_mpa', + 'notes', + 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', + 'created_at', 'updated_at', + ] + read_only_fields = ['status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at'] + + +class CaliberListSerializer(serializers.ModelSerializer): + class Meta: + model = Caliber + fields = ['id', 'name', 'short_name', 'status', 'max_pressure_mpa'] diff --git a/apps/calibers/urls.py b/apps/calibers/urls.py new file mode 100644 index 0000000..6a142ce --- /dev/null +++ b/apps/calibers/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from .views import CaliberViewSet + +router = DefaultRouter() +router.register(r'calibers', CaliberViewSet, basename='caliber') + +urlpatterns = router.urls diff --git a/apps/calibers/views.py b/apps/calibers/views.py new file mode 100644 index 0000000..2c45de2 --- /dev/null +++ b/apps/calibers/views.py @@ -0,0 +1,50 @@ +from django.db.models import Q +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated +from rest_framework.response import Response + +from .models import Caliber, CaliberStatus +from .serializers import CaliberListSerializer, CaliberSerializer + + +class CaliberViewSet(viewsets.ModelViewSet): + queryset = Caliber.objects.select_related('submitted_by', 'reviewed_by') + serializer_class = CaliberSerializer + filterset_fields = ['status'] + search_fields = ['name', 'short_name'] + + def get_permissions(self): + if self.action in ('list', 'retrieve'): + return [AllowAny()] + if self.action in ('update', 'partial_update', 'destroy'): + return [IsAdminUser()] + return [IsAuthenticated()] + + def get_queryset(self): + qs = super().get_queryset() + user = self.request.user + if user.is_authenticated: + return qs.filter( + Q(status=CaliberStatus.VERIFIED) | + Q(status=CaliberStatus.PENDING, submitted_by=user) + ) + return qs.filter(status=CaliberStatus.VERIFIED) + + def perform_create(self, serializer): + serializer.save( + status=CaliberStatus.PENDING, + submitted_by=self.request.user, + ) + + @action(detail=True, methods=['post'], permission_classes=[IsAdminUser]) + def verify(self, request, pk=None): + caliber = self.get_object() + caliber.verify(request.user) + return Response(self.get_serializer(caliber).data) + + @action(detail=True, methods=['post'], permission_classes=[IsAdminUser]) + def reject(self, request, pk=None): + caliber = self.get_object() + caliber.reject(request.user) + return Response(self.get_serializer(caliber).data) diff --git a/apps/common/__init__.py b/apps/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/common/serializer_helpers.py b/apps/common/serializer_helpers.py new file mode 100644 index 0000000..21b697f --- /dev/null +++ b/apps/common/serializer_helpers.py @@ -0,0 +1,22 @@ +"""Shared serializer helper functions used across multiple apps.""" + + +def ammo_detail(ammo): + if ammo is None: + return None + cal = ammo.caliber + return { + 'id': ammo.id, 'brand': ammo.brand, 'name': ammo.name, + 'caliber_detail': {'id': cal.id, 'name': cal.name} if cal else None, + } + + +def batch_detail(batch): + if batch is None: + return None + return { + 'id': batch.id, + 'recipe_name': batch.recipe.name, + 'powder': str(batch.powder), + 'powder_charge_gr': str(batch.powder_charge_gr), + } diff --git a/apps/gears/__init__.py b/apps/gears/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gears/admin.py b/apps/gears/admin.py new file mode 100644 index 0000000..69adb7e --- /dev/null +++ b/apps/gears/admin.py @@ -0,0 +1,171 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import ( + Ammo, + Bipod, + Brass, + Bullet, + Firearm, + Magazine, + Powder, + Primer, + ReloadedAmmoBatch, + ReloadRecipe, + Rig, + RigItem, + Scope, + Suppressor, + UserGear, +) + + +class GearAdminBase(admin.ModelAdmin): + list_display = ('brand', 'model_name', 'status', 'submitted_by', 'reviewed_by', 'created_at') + list_filter = ('status',) + search_fields = ('brand', 'model_name') + readonly_fields = ('gear_type', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at') + actions = ['verify_gears', 'reject_gears'] + + @admin.action(description=_('Mark selected gears as Verified')) + def verify_gears(self, request, queryset): + for gear in queryset: + gear.verify(reviewed_by=request.user) + + @admin.action(description=_('Mark selected gears as Rejected')) + def reject_gears(self, request, queryset): + for gear in queryset: + gear.reject(reviewed_by=request.user) + + +@admin.register(Firearm) +class FirearmAdmin(GearAdminBase): + list_display = GearAdminBase.list_display + ('firearm_type', 'caliber') + list_filter = GearAdminBase.list_filter + ('firearm_type',) + + +@admin.register(Scope) +class ScopeAdmin(GearAdminBase): + list_display = GearAdminBase.list_display + ('magnification_min', 'magnification_max', 'objective_diameter_mm') + + +@admin.register(Suppressor) +class SuppressorAdmin(GearAdminBase): + list_display = GearAdminBase.list_display + ('max_caliber', 'thread_pitch') + + +@admin.register(Bipod) +class BipodAdmin(GearAdminBase): + list_display = GearAdminBase.list_display + ('attachment_type',) + + +@admin.register(Magazine) +class MagazineAdmin(GearAdminBase): + list_display = GearAdminBase.list_display + ('caliber', 'capacity') + + +@admin.register(UserGear) +class UserGearAdmin(admin.ModelAdmin): + list_display = ('user', 'gear', 'nickname', 'serial_number', 'added_at') + search_fields = ('user__email', 'gear__brand', 'gear__model_name', 'nickname') + raw_id_fields = ('user', 'gear') + + +class RigItemInline(admin.TabularInline): + model = RigItem + extra = 0 + raw_id_fields = ('user_gear',) + + +@admin.register(Rig) +class RigAdmin(admin.ModelAdmin): + list_display = ('name', 'user', 'created_at') + search_fields = ('name', 'user__email') + inlines = [RigItemInline] + + +@admin.register(RigItem) +class RigItemAdmin(admin.ModelAdmin): + list_display = ('rig', 'user_gear', 'role') + list_filter = ('role',) + search_fields = ('rig__name', 'user_gear__gear__brand', 'user_gear__gear__model_name') + raw_id_fields = ('rig', 'user_gear') + + +# ── Ammo catalog ────────────────────────────────────────────────────────────── + +@admin.register(Ammo) +class AmmoAdmin(admin.ModelAdmin): + list_display = ('brand', 'name', 'caliber', 'bullet_weight_gr', 'bullet_type', 'status', 'submitted_by') + list_filter = ('status', 'bullet_type', 'caliber', 'case_material') + search_fields = ('brand', 'name', 'caliber') + readonly_fields = ('submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at') + raw_id_fields = ('submitted_by', 'reviewed_by') + actions = ['verify_ammo', 'reject_ammo'] + + @admin.action(description=_('Mark selected ammo as Verified')) + def verify_ammo(self, request, queryset): + for ammo in queryset: + ammo.verify(reviewed_by=request.user) + + @admin.action(description=_('Mark selected ammo as Rejected')) + def reject_ammo(self, request, queryset): + for ammo in queryset: + ammo.reject(reviewed_by=request.user) + + +# ── Reloading components ─────────────────────────────────────────────────────── + +@admin.register(Primer) +class PrimerAdmin(admin.ModelAdmin): + list_display = ('brand', 'name', 'size') + list_filter = ('size',) + search_fields = ('brand', 'name') + + +@admin.register(Brass) +class BrassAdmin(admin.ModelAdmin): + list_display = ('brand', 'caliber', 'primer_pocket', 'trim_length_mm') + list_filter = ('caliber',) + search_fields = ('brand', 'caliber') + + +@admin.register(Bullet) +class BulletAdmin(admin.ModelAdmin): + list_display = ('brand', 'model_name', 'weight_gr', 'bullet_type', 'diameter_mm') + list_filter = ('bullet_type',) + search_fields = ('brand', 'model_name') + + +@admin.register(Powder) +class PowderAdmin(admin.ModelAdmin): + list_display = ('brand', 'name', 'powder_type', 'burn_rate_index') + list_filter = ('powder_type',) + search_fields = ('brand', 'name') + ordering = ('burn_rate_index',) + + +# ── Reload development ──────────────────────────────────────────────────────── + +class ReloadedAmmoBatchInline(admin.TabularInline): + model = ReloadedAmmoBatch + extra = 0 + show_change_link = True + fields = ('powder', 'powder_charge_gr', 'quantity', 'oal_mm', 'loaded_at') + raw_id_fields = ('powder',) + + +@admin.register(ReloadRecipe) +class ReloadRecipeAdmin(admin.ModelAdmin): + list_display = ('name', 'user', 'caliber', 'primer', 'brass', 'bullet', 'created_at') + search_fields = ('name', 'user__email', 'caliber') + raw_id_fields = ('user', 'primer', 'brass', 'bullet') + inlines = [ReloadedAmmoBatchInline] + + +@admin.register(ReloadedAmmoBatch) +class ReloadedAmmoBatchAdmin(admin.ModelAdmin): + list_display = ('recipe', 'powder', 'powder_charge_gr', 'quantity', 'loaded_at') + search_fields = ('recipe__name', 'powder__name') + list_filter = ('loaded_at',) + raw_id_fields = ('recipe', 'powder') diff --git a/apps/gears/apps.py b/apps/gears/apps.py new file mode 100644 index 0000000..e63bc35 --- /dev/null +++ b/apps/gears/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GearsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.gears' diff --git a/apps/gears/management/__init__.py b/apps/gears/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gears/management/commands/__init__.py b/apps/gears/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gears/management/commands/import_weapons_csv.py b/apps/gears/management/commands/import_weapons_csv.py new file mode 100644 index 0000000..c018b9e --- /dev/null +++ b/apps/gears/management/commands/import_weapons_csv.py @@ -0,0 +1,216 @@ +""" +Management command: import_weapons_csv + +Reads weapon.csv (French RGA export, ';'-separated) and: + 1. Creates Caliber instances for all unique caliber names (status=VERIFIED). + 2. Creates Firearm instances for each row where the weapon type is + mappable to a FirearmType (skips air guns, NL weapons, etc.). + +CSV column layout (1-indexed, matching the header): + 1 referenceRGA + 2 famille (EPAULE / POING) + 3 typeArme (weapon type in French) + 4 marque (brand) + 5 modele (model name) + 6 fabricant + 7 paysFabricant + 8 modeFonctionnement + 9 systemeAlimentation + 10 longueurArme + 11 capaciteHorsChambre + 12 capaciteChambre + 13 calibreCanonUn ← primary caliber + 14 modePercussionCanonUn + 15 typeCanonUn + 16 longueurCanonUn (barrel length mm) + +Usage: + python manage.py import_weapons_csv /path/to/weapon.csv + python manage.py import_weapons_csv /path/to/weapon.csv --dry-run +""" + +import csv +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + + +FIREARM_TYPE_MAP = { + 'CARABINE': 'CARBINE', + 'CARABINE A BARILLET': 'CARBINE', + 'FUSIL': 'SHOTGUN', + "FUSIL A POMPE": 'SHOTGUN', + "FUSIL D'ASSAUT": 'RIFLE', + "FUSIL (FAP MODIFIE 1 COUP)": 'SHOTGUN', + "FUSIL SEMI-AUTOMATIQUE ET A POMPE": 'SHOTGUN', + 'PISTOLET': 'PISTOL', + 'REVOLVER': 'REVOLVER', +} + + +def _clean(val: str) -> str: + """Strip surrounding whitespace and quotation marks.""" + return val.strip().strip('"').strip("'").strip() + + +class Command(BaseCommand): + help = 'Import calibers and firearms from the French RGA weapon CSV.' + + def add_arguments(self, parser): + parser.add_argument('csv_path', type=str, help='Path to weapon.csv') + parser.add_argument( + '--dry-run', action='store_true', + help='Parse and count without writing to the database.', + ) + + def handle(self, *args, **options): + from apps.calibers.models import Caliber, CaliberStatus + from apps.gears.models import Firearm, GearStatus + + csv_path = options['csv_path'] + dry_run = options['dry_run'] + + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN — nothing will be saved.')) + + # ── Pass 1: collect unique caliber names ────────────────────────────── + caliber_names = set() + rows = [] + + self.stdout.write('Reading CSV…') + with open(csv_path, encoding='utf-8', errors='replace') as fh: + reader = csv.reader(fh, delimiter=';') + next(reader) # skip header + for row in reader: + if len(row) < 13: + continue + cal_raw = _clean(row[12]) + type_raw = _clean(row[2]) + if cal_raw and type_raw in FIREARM_TYPE_MAP: + caliber_names.add(cal_raw) + rows.append(row) + + self.stdout.write(f' {len(rows)} data rows, {len(caliber_names)} unique calibers to import.') + + if dry_run: + fw_count = sum( + 1 for r in rows + if len(r) >= 5 and _clean(r[2]) in FIREARM_TYPE_MAP and _clean(r[4]) + ) + self.stdout.write(f' ~{fw_count} firearms would be created.') + return + + # ── Pass 2: upsert Caliber instances ───────────────────────────────── + self.stdout.write('Upserting calibers…') + caliber_map = {} # name → Caliber pk + + existing = {c.name: c for c in Caliber.objects.filter(name__in=caliber_names)} + caliber_map.update({name: cal.pk for name, cal in existing.items()}) + + new_calibers = [] + now = timezone.now() + for name in caliber_names: + if name not in existing: + new_calibers.append(Caliber( + name=name, + status=CaliberStatus.VERIFIED, + reviewed_at=now, + )) + + if new_calibers: + created = Caliber.objects.bulk_create(new_calibers, batch_size=500) + for c in created: + caliber_map[c.name] = c.pk + self.stdout.write(f' Created {len(created)} new calibers ({len(existing)} already existed).') + else: + self.stdout.write(f' All {len(existing)} calibers already existed.') + + # ── Pass 3: import firearms ─────────────────────────────────────────── + self.stdout.write('Importing firearms…') + + # Build set of existing (brand, model_name) pairs to avoid duplicates + existing_firearms = set( + Firearm.objects.values_list('brand', 'model_name') + ) + + to_create = [] + skipped_type = 0 + skipped_dup = 0 + skipped_no_model = 0 + + for row in rows: + if len(row) < 5: + continue + + type_raw = _clean(row[2]) + firearm_type = FIREARM_TYPE_MAP.get(type_raw) + if not firearm_type: + skipped_type += 1 + continue + + brand = _clean(row[3]) + model = _clean(row[4]) + if not model: + skipped_no_model += 1 + continue + if not brand: + brand = '(unknown)' + + if (brand, model) in existing_firearms: + skipped_dup += 1 + continue + + cal_raw = _clean(row[12]) if len(row) > 12 else '' + cal_pk = caliber_map.get(cal_raw) if cal_raw else None + + barrel_mm = None + if len(row) > 15: + try: + barrel_mm = float(_clean(row[15]).replace(',', '.')) + if barrel_mm <= 0: + barrel_mm = None + except (ValueError, AttributeError): + barrel_mm = None + + cap_extra = None + if len(row) > 10: + try: + cap_extra = int(_clean(row[10])) + except (ValueError, AttributeError): + pass + + to_create.append(Firearm( + brand = brand, + model_name = model, + firearm_type = firearm_type, + caliber_id = cal_pk, + barrel_length_mm = barrel_mm, + magazine_capacity = cap_extra if cap_extra else None, + status = GearStatus.VERIFIED, + reviewed_at = now, + )) + # Track to prevent within-batch duplicates + existing_firearms.add((brand, model)) + + self.stdout.write( + f' {len(to_create)} firearms to create ' + f'({skipped_dup} duplicates, {skipped_type} unsupported types, ' + f'{skipped_no_model} no model name).' + ) + + with transaction.atomic(): + # Firearm inherits from Gear (MTI) so bulk_create won't work directly. + # Use chunked individual creates instead. + chunk = 500 + for i in range(0, len(to_create), chunk): + batch = to_create[i:i + chunk] + for fw in batch: + fw.save() + pct = min(i + chunk, len(to_create)) + self.stdout.write(f' … {pct}/{len(to_create)}', ending='\r') + self.stdout.flush() + + self.stdout.write('') + self.stdout.write(self.style.SUCCESS( + f'Done. {len(to_create)} firearms and {len(new_calibers)} calibers imported.' + )) diff --git a/apps/gears/migrations/0001_initial.py b/apps/gears/migrations/0001_initial.py new file mode 100644 index 0000000..a299a16 --- /dev/null +++ b/apps/gears/migrations/0001_initial.py @@ -0,0 +1,136 @@ +# Generated by Django 4.2.16 on 2026-03-24 09:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Gear', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('brand', models.CharField(max_length=100)), + ('model_name', models.CharField(max_length=150)), + ('description', models.TextField(blank=True)), + ('gear_type', models.CharField(choices=[('FIREARM', 'Firearm'), ('SCOPE', 'Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine')], editable=False, max_length=20)), + ('status', models.CharField(choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], default='PENDING', max_length=10)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['brand', 'model_name'], + }, + ), + migrations.CreateModel( + name='Rig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='RigItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('PRIMARY', 'Primary Firearm'), ('OPTIC', 'Optic / Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine'), ('OTHER', 'Other Accessory')], default='OTHER', max_length=20)), + ], + ), + migrations.CreateModel( + name='Bipod', + fields=[ + ('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')), + ('min_height_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)), + ('max_height_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)), + ('attachment_type', models.CharField(blank=True, choices=[('PICATINNY', 'Picatinny Rail'), ('SLING_STUD', 'Sling Stud'), ('ARCA_SWISS', 'Arca-Swiss'), ('M_LOK', 'M-LOK'), ('KEYMOD', 'KeyMod')], max_length=20)), + ], + options={ + 'verbose_name': 'Bipod', + }, + bases=('gears.gear',), + ), + migrations.CreateModel( + name='Firearm', + fields=[ + ('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')), + ('firearm_type', models.CharField(choices=[('RIFLE', 'Rifle'), ('PISTOL', 'Pistol'), ('SHOTGUN', 'Shotgun'), ('REVOLVER', 'Revolver'), ('CARBINE', 'Carbine')], max_length=10)), + ('caliber', models.CharField(max_length=20)), + ('action', models.CharField(choices=[('BOLT', 'Bolt Action'), ('SEMI_AUTO', 'Semi-Automatic'), ('PUMP', 'Pump Action'), ('LEVER', 'Lever Action'), ('BREAK', 'Break Action'), ('FULL_AUTO', 'Full Automatic')], max_length=10)), + ('barrel_length_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)), + ('magazine_capacity', models.PositiveSmallIntegerField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'Firearm', + }, + bases=('gears.gear',), + ), + migrations.CreateModel( + name='Magazine', + fields=[ + ('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')), + ('caliber', models.CharField(max_length=20)), + ('capacity', models.PositiveSmallIntegerField()), + ], + options={ + 'verbose_name': 'Magazine', + }, + bases=('gears.gear',), + ), + migrations.CreateModel( + name='Scope', + fields=[ + ('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')), + ('magnification_min', models.DecimalField(decimal_places=1, max_digits=5)), + ('magnification_max', models.DecimalField(decimal_places=1, max_digits=5)), + ('objective_diameter_mm', models.DecimalField(decimal_places=1, max_digits=5)), + ('tube_diameter_mm', models.DecimalField(decimal_places=1, default=30, max_digits=5)), + ('reticle_type', models.CharField(blank=True, choices=[('DUPLEX', 'Duplex'), ('MILDOT', 'Mil-Dot'), ('BDC', 'BDC'), ('ILLUMINATED', 'Illuminated'), ('ETCHED', 'Etched Glass')], max_length=20)), + ], + options={ + 'verbose_name': 'Scope', + }, + bases=('gears.gear',), + ), + migrations.CreateModel( + name='Suppressor', + fields=[ + ('gear_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='gears.gear')), + ('max_caliber', models.CharField(max_length=20)), + ('thread_pitch', models.CharField(blank=True, max_length=20)), + ('length_mm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)), + ('weight_g', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True)), + ], + options={ + 'verbose_name': 'Suppressor', + }, + bases=('gears.gear',), + ), + migrations.CreateModel( + name='UserGear', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nickname', models.CharField(blank=True, max_length=100)), + ('serial_number', models.CharField(blank=True, max_length=100)), + ('purchase_date', models.DateField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('added_at', models.DateTimeField(auto_now_add=True)), + ('gear', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_instances', to='gears.gear')), + ], + options={ + 'ordering': ['-added_at'], + }, + ), + ] diff --git a/apps/gears/migrations/0002_initial.py b/apps/gears/migrations/0002_initial.py new file mode 100644 index 0000000..a6ba2fa --- /dev/null +++ b/apps/gears/migrations/0002_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.16 on 2026-03-24 09:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('gears', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='usergear', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='rigitem', + name='rig', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.rig'), + ), + migrations.AddField( + model_name='rigitem', + name='user_gear', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.usergear'), + ), + migrations.AddField( + model_name='rig', + name='items', + field=models.ManyToManyField(related_name='rigs', through='gears.RigItem', to='gears.usergear'), + ), + migrations.AddField( + model_name='rig', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rigs', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='gear', + name='reviewed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_gears', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='gear', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_gears', to=settings.AUTH_USER_MODEL), + ), + migrations.AddConstraint( + model_name='rigitem', + constraint=models.UniqueConstraint(fields=('rig', 'user_gear'), name='unique_gear_per_rig'), + ), + migrations.AddConstraint( + model_name='rig', + constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_rig_per_user'), + ), + migrations.AddConstraint( + model_name='gear', + constraint=models.UniqueConstraint(fields=('brand', 'model_name'), name='unique_gear_brand_model'), + ), + ] diff --git a/apps/gears/migrations/0003_ammo_brass_bullet_powder_primer_alter_bipod_options_and_more.py b/apps/gears/migrations/0003_ammo_brass_bullet_powder_primer_alter_bipod_options_and_more.py new file mode 100644 index 0000000..8b99e34 --- /dev/null +++ b/apps/gears/migrations/0003_ammo_brass_bullet_powder_primer_alter_bipod_options_and_more.py @@ -0,0 +1,450 @@ +# Generated by Django 4.2.16 on 2026-03-24 13:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gears', '0002_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Ammo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('brand', models.CharField(max_length=100, verbose_name='brand')), + ('name', models.CharField(max_length=150, verbose_name='name')), + ('caliber', models.CharField(max_length=20, verbose_name='caliber')), + ('bullet_weight_gr', models.DecimalField(decimal_places=1, max_digits=6, verbose_name='bullet weight (gr)')), + ('bullet_type', models.CharField(choices=[('FMJ', 'Full Metal Jacket'), ('HP', 'Hollow Point'), ('BTHP', 'Boat Tail Hollow Point'), ('SP', 'Soft Point'), ('HPBT', 'Hollow Point Boat Tail'), ('SMK', 'Sierra MatchKing'), ('A_TIP', 'Hornady A-Tip'), ('MONO', 'Monolithic / Solid')], max_length=5, verbose_name='bullet type')), + ('primer_size', models.CharField(blank=True, choices=[('SP', 'Small Pistol'), ('LP', 'Large Pistol'), ('SR', 'Small Rifle'), ('LR', 'Large Rifle'), ('LRM', 'Large Rifle Magnum')], max_length=3, verbose_name='primer size')), + ('case_material', models.CharField(choices=[('BRASS', 'Brass'), ('STEEL', 'Steel'), ('ALUMINUM', 'Aluminum'), ('NICKEL', 'Nickel-Plated Brass')], default='BRASS', max_length=10, verbose_name='case material')), + ('muzzle_velocity_fps', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='muzzle velocity (fps)')), + ('muzzle_energy_ftlb', models.DecimalField(blank=True, decimal_places=1, max_digits=7, null=True, verbose_name='muzzle energy (ft·lb)')), + ('box_count', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='box count')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('status', models.CharField(choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], default='PENDING', max_length=10, verbose_name='status')), + ('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='reviewed at')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'ammo', + 'verbose_name_plural': 'ammo', + 'ordering': ['brand', 'name', 'caliber'], + }, + ), + migrations.CreateModel( + name='Brass', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('brand', models.CharField(max_length=100, verbose_name='brand')), + ('caliber', models.CharField(max_length=20, verbose_name='caliber')), + ('primer_pocket', models.CharField(blank=True, choices=[('SP', 'Small Pistol'), ('LP', 'Large Pistol'), ('SR', 'Small Rifle'), ('LR', 'Large Rifle'), ('LRM', 'Large Rifle Magnum')], max_length=3, verbose_name='primer pocket')), + ('trim_length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='trim-to length (mm)')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ], + options={ + 'verbose_name': 'brass', + 'verbose_name_plural': 'brass', + 'ordering': ['brand', 'caliber'], + }, + ), + migrations.CreateModel( + name='Bullet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('brand', models.CharField(max_length=100, verbose_name='brand')), + ('model_name', models.CharField(max_length=150, verbose_name='model name')), + ('weight_gr', models.DecimalField(decimal_places=1, max_digits=6, verbose_name='weight (gr)')), + ('bullet_type', models.CharField(choices=[('FMJ', 'Full Metal Jacket'), ('HP', 'Hollow Point'), ('BTHP', 'Boat Tail Hollow Point'), ('SP', 'Soft Point'), ('HPBT', 'Hollow Point Boat Tail'), ('SMK', 'Sierra MatchKing'), ('A_TIP', 'Hornady A-Tip'), ('MONO', 'Monolithic / Solid')], max_length=5, verbose_name='bullet type')), + ('diameter_mm', models.DecimalField(blank=True, decimal_places=3, max_digits=5, null=True, verbose_name='diameter (mm)')), + ('length_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='length (mm)')), + ('bc_g1', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, verbose_name='BC (G1)')), + ('bc_g7', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True, verbose_name='BC (G7)')), + ], + options={ + 'verbose_name': 'bullet', + 'verbose_name_plural': 'bullets', + 'ordering': ['brand', 'model_name', 'weight_gr'], + }, + ), + migrations.CreateModel( + name='Powder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('brand', models.CharField(max_length=100, verbose_name='brand')), + ('name', models.CharField(max_length=100, verbose_name='name')), + ('powder_type', models.CharField(blank=True, choices=[('BALL', 'Ball / Spherical'), ('EXTRUDED', 'Extruded / Stick'), ('FLAKE', 'Flake')], max_length=10, verbose_name='powder type')), + ('burn_rate_index', models.PositiveSmallIntegerField(blank=True, help_text='Lower = faster burning. Used for relative ordering only.', null=True, verbose_name='burn rate index')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ], + options={ + 'verbose_name': 'powder', + 'verbose_name_plural': 'powders', + 'ordering': ['burn_rate_index', 'brand', 'name'], + }, + ), + migrations.CreateModel( + name='Primer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('brand', models.CharField(max_length=100, verbose_name='brand')), + ('name', models.CharField(max_length=100, verbose_name='name')), + ('size', models.CharField(choices=[('SP', 'Small Pistol'), ('LP', 'Large Pistol'), ('SR', 'Small Rifle'), ('LR', 'Large Rifle'), ('LRM', 'Large Rifle Magnum')], max_length=3, verbose_name='size')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ], + options={ + 'verbose_name': 'primer', + 'verbose_name_plural': 'primers', + 'ordering': ['brand', 'name'], + }, + ), + migrations.AlterModelOptions( + name='bipod', + options={'verbose_name': 'bipod', 'verbose_name_plural': 'bipods'}, + ), + migrations.AlterModelOptions( + name='firearm', + options={'verbose_name': 'firearm', 'verbose_name_plural': 'firearms'}, + ), + migrations.AlterModelOptions( + name='gear', + options={'ordering': ['brand', 'model_name'], 'verbose_name': 'gear', 'verbose_name_plural': 'gears'}, + ), + migrations.AlterModelOptions( + name='magazine', + options={'verbose_name': 'magazine', 'verbose_name_plural': 'magazines'}, + ), + migrations.AlterModelOptions( + name='rig', + options={'ordering': ['-created_at'], 'verbose_name': 'rig', 'verbose_name_plural': 'rigs'}, + ), + migrations.AlterModelOptions( + name='rigitem', + options={'verbose_name': 'rig item', 'verbose_name_plural': 'rig items'}, + ), + migrations.AlterModelOptions( + name='scope', + options={'verbose_name': 'scope', 'verbose_name_plural': 'scopes'}, + ), + migrations.AlterModelOptions( + name='suppressor', + options={'verbose_name': 'suppressor', 'verbose_name_plural': 'suppressors'}, + ), + migrations.AlterModelOptions( + name='usergear', + options={'ordering': ['-added_at'], 'verbose_name': 'owned gear', 'verbose_name_plural': 'owned gears'}, + ), + migrations.AlterField( + model_name='bipod', + name='attachment_type', + field=models.CharField(blank=True, choices=[('PICATINNY', 'Picatinny Rail'), ('SLING_STUD', 'Sling Stud'), ('ARCA_SWISS', 'Arca-Swiss'), ('M_LOK', 'M-LOK'), ('KEYMOD', 'KeyMod')], max_length=20, verbose_name='attachment type'), + ), + migrations.AlterField( + model_name='bipod', + name='max_height_mm', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='max height (mm)'), + ), + migrations.AlterField( + model_name='bipod', + name='min_height_mm', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='min height (mm)'), + ), + migrations.AlterField( + model_name='firearm', + name='action', + field=models.CharField(choices=[('BOLT', 'Bolt Action'), ('SEMI_AUTO', 'Semi-Automatic'), ('PUMP', 'Pump Action'), ('LEVER', 'Lever Action'), ('BREAK', 'Break Action'), ('FULL_AUTO', 'Full Automatic')], max_length=10, verbose_name='action'), + ), + migrations.AlterField( + model_name='firearm', + name='barrel_length_mm', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='barrel length (mm)'), + ), + migrations.AlterField( + model_name='firearm', + name='caliber', + field=models.CharField(max_length=20, verbose_name='caliber'), + ), + migrations.AlterField( + model_name='firearm', + name='firearm_type', + field=models.CharField(choices=[('RIFLE', 'Rifle'), ('PISTOL', 'Pistol'), ('SHOTGUN', 'Shotgun'), ('REVOLVER', 'Revolver'), ('CARBINE', 'Carbine')], max_length=10, verbose_name='firearm type'), + ), + migrations.AlterField( + model_name='firearm', + name='magazine_capacity', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='magazine capacity'), + ), + migrations.AlterField( + model_name='gear', + name='brand', + field=models.CharField(max_length=100, verbose_name='brand'), + ), + migrations.AlterField( + model_name='gear', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='created at'), + ), + migrations.AlterField( + model_name='gear', + name='description', + field=models.TextField(blank=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='gear', + name='gear_type', + field=models.CharField(choices=[('FIREARM', 'Firearm'), ('SCOPE', 'Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine')], editable=False, max_length=20, verbose_name='gear type'), + ), + migrations.AlterField( + model_name='gear', + name='model_name', + field=models.CharField(max_length=150, verbose_name='model name'), + ), + migrations.AlterField( + model_name='gear', + name='reviewed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='reviewed at'), + ), + migrations.AlterField( + model_name='gear', + name='reviewed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_gears', to=settings.AUTH_USER_MODEL, verbose_name='reviewed by'), + ), + migrations.AlterField( + model_name='gear', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], default='PENDING', max_length=10, verbose_name='status'), + ), + migrations.AlterField( + model_name='gear', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_gears', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'), + ), + migrations.AlterField( + model_name='gear', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='updated at'), + ), + migrations.AlterField( + model_name='magazine', + name='caliber', + field=models.CharField(max_length=20, verbose_name='caliber'), + ), + migrations.AlterField( + model_name='magazine', + name='capacity', + field=models.PositiveSmallIntegerField(verbose_name='capacity'), + ), + migrations.AlterField( + model_name='rig', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='created at'), + ), + migrations.AlterField( + model_name='rig', + name='description', + field=models.TextField(blank=True, verbose_name='description'), + ), + migrations.AlterField( + model_name='rig', + name='items', + field=models.ManyToManyField(related_name='rigs', through='gears.RigItem', to='gears.usergear', verbose_name='items'), + ), + migrations.AlterField( + model_name='rig', + name='name', + field=models.CharField(max_length=100, verbose_name='name'), + ), + migrations.AlterField( + model_name='rig', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='updated at'), + ), + migrations.AlterField( + model_name='rig', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rigs', to=settings.AUTH_USER_MODEL, verbose_name='user'), + ), + migrations.AlterField( + model_name='rigitem', + name='rig', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.rig', verbose_name='rig'), + ), + migrations.AlterField( + model_name='rigitem', + name='role', + field=models.CharField(choices=[('PRIMARY', 'Primary Firearm'), ('OPTIC', 'Optic / Scope'), ('SUPPRESSOR', 'Suppressor'), ('BIPOD', 'Bipod'), ('MAGAZINE', 'Magazine'), ('OTHER', 'Other Accessory')], default='OTHER', max_length=20, verbose_name='role'), + ), + migrations.AlterField( + model_name='rigitem', + name='user_gear', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rig_items', to='gears.usergear', verbose_name='gear'), + ), + migrations.AlterField( + model_name='scope', + name='magnification_max', + field=models.DecimalField(decimal_places=1, max_digits=5, verbose_name='max magnification'), + ), + migrations.AlterField( + model_name='scope', + name='magnification_min', + field=models.DecimalField(decimal_places=1, max_digits=5, verbose_name='min magnification'), + ), + migrations.AlterField( + model_name='scope', + name='objective_diameter_mm', + field=models.DecimalField(decimal_places=1, max_digits=5, verbose_name='objective diameter (mm)'), + ), + migrations.AlterField( + model_name='scope', + name='reticle_type', + field=models.CharField(blank=True, choices=[('DUPLEX', 'Duplex'), ('MILDOT', 'Mil-Dot'), ('BDC', 'BDC'), ('ILLUMINATED', 'Illuminated'), ('ETCHED', 'Etched Glass')], max_length=20, verbose_name='reticle type'), + ), + migrations.AlterField( + model_name='scope', + name='tube_diameter_mm', + field=models.DecimalField(decimal_places=1, default=30, max_digits=5, verbose_name='tube diameter (mm)'), + ), + migrations.AlterField( + model_name='suppressor', + name='length_mm', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='length (mm)'), + ), + migrations.AlterField( + model_name='suppressor', + name='max_caliber', + field=models.CharField(max_length=20, verbose_name='max caliber'), + ), + migrations.AlterField( + model_name='suppressor', + name='thread_pitch', + field=models.CharField(blank=True, max_length=20, verbose_name='thread pitch'), + ), + migrations.AlterField( + model_name='suppressor', + name='weight_g', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='weight (g)'), + ), + migrations.AlterField( + model_name='usergear', + name='added_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='added at'), + ), + migrations.AlterField( + model_name='usergear', + name='gear', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_instances', to='gears.gear', verbose_name='gear'), + ), + migrations.AlterField( + model_name='usergear', + name='nickname', + field=models.CharField(blank=True, max_length=100, verbose_name='nickname'), + ), + migrations.AlterField( + model_name='usergear', + name='notes', + field=models.TextField(blank=True, verbose_name='notes'), + ), + migrations.AlterField( + model_name='usergear', + name='purchase_date', + field=models.DateField(blank=True, null=True, verbose_name='purchase date'), + ), + migrations.AlterField( + model_name='usergear', + name='serial_number', + field=models.CharField(blank=True, max_length=100, verbose_name='serial number'), + ), + migrations.AlterField( + model_name='usergear', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to=settings.AUTH_USER_MODEL, verbose_name='user'), + ), + migrations.CreateModel( + name='ReloadRecipe', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, verbose_name='name')), + ('caliber', models.CharField(max_length=20, verbose_name='caliber')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('brass', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to='gears.brass', verbose_name='brass')), + ('bullet', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to='gears.bullet', verbose_name='bullet')), + ('primer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipes', to='gears.primer', verbose_name='primer')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reload_recipes', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'reload recipe', + 'verbose_name_plural': 'reload recipes', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ReloadedAmmoBatch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('powder_charge_gr', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='powder charge (gr)')), + ('quantity', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='quantity loaded')), + ('oal_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='overall length (mm)')), + ('coal_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='cartridge overall length to ogive (mm)')), + ('crimp', models.CharField(choices=[('NONE', 'No Crimp'), ('TAPER', 'Taper Crimp'), ('ROLL', 'Roll Crimp')], default='NONE', max_length=6, verbose_name='crimp')), + ('case_prep_notes', models.TextField(blank=True, verbose_name='case prep notes')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('loaded_at', models.DateField(blank=True, null=True, verbose_name='loaded at')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('powder', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='batches', to='gears.powder', verbose_name='powder')), + ('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='batches', to='gears.reloadrecipe', verbose_name='recipe')), + ], + options={ + 'verbose_name': 'reloaded ammo batch', + 'verbose_name_plural': 'reloaded ammo batches', + 'ordering': ['recipe', 'powder_charge_gr'], + }, + ), + migrations.AddConstraint( + model_name='primer', + constraint=models.UniqueConstraint(fields=('brand', 'name'), name='unique_primer_brand_name'), + ), + migrations.AddConstraint( + model_name='powder', + constraint=models.UniqueConstraint(fields=('brand', 'name'), name='unique_powder_brand_name'), + ), + migrations.AddConstraint( + model_name='bullet', + constraint=models.UniqueConstraint(fields=('brand', 'model_name', 'weight_gr'), name='unique_bullet_brand_model_weight'), + ), + migrations.AddConstraint( + model_name='brass', + constraint=models.UniqueConstraint(fields=('brand', 'caliber'), name='unique_brass_brand_caliber'), + ), + migrations.AddField( + model_name='ammo', + name='reviewed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_ammo', to=settings.AUTH_USER_MODEL, verbose_name='reviewed by'), + ), + migrations.AddField( + model_name='ammo', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_ammo', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'), + ), + migrations.AddConstraint( + model_name='reloadrecipe', + constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_recipe_name_per_user'), + ), + migrations.AddConstraint( + model_name='reloadedammobatch', + constraint=models.UniqueConstraint(fields=('recipe', 'powder', 'powder_charge_gr'), name='unique_batch_charge_per_recipe_powder'), + ), + migrations.AddConstraint( + model_name='ammo', + constraint=models.UniqueConstraint(fields=('brand', 'name', 'caliber'), name='unique_ammo_brand_name_caliber'), + ), + ] diff --git a/apps/gears/migrations/0004_catalog_initial_data.py b/apps/gears/migrations/0004_catalog_initial_data.py new file mode 100644 index 0000000..92d5550 --- /dev/null +++ b/apps/gears/migrations/0004_catalog_initial_data.py @@ -0,0 +1,1035 @@ +""" +Data migration: pre-populate the public catalog with well-known commercial +firearms, scopes, suppressors, bipods, magazines, factory ammo, and reloading +components (primers, brass, bullets, powders). + +All Gear sub-class objects must set gear_type explicitly — Django does NOT run +custom save() overrides when using historical models inside data migrations. +""" +from django.db import migrations + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get(apps, model_name): + return apps.get_model('gears', model_name) + + +def _verified(apps): + return 'VERIFIED' + + +# --------------------------------------------------------------------------- +# Populate functions +# --------------------------------------------------------------------------- + +def populate_firearms(apps, schema_editor): + Firearm = _get(apps, 'Firearm') + status = _verified(apps) + + firearms = [ + # ── Bolt-action rifles ────────────────────────────────────────────── + dict(brand='Remington', model_name='700 ADL', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Remington', model_name='700 BDL', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.30-06 Springfield', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Remington', model_name='700 SPS', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Remington', model_name='700 SPS Tactical', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=508, status=status), + dict(brand='Remington', model_name='700 5R', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Remington', model_name='700 PCR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Tikka', model_name='T3x Lite', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=600, status=status), + dict(brand='Tikka', model_name='T3x TAC A1', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Tikka', model_name='T3x TAC A1 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Tikka', model_name='T3x Sporter', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=600, status=status), + dict(brand='Tikka', model_name='T3x CTR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=510, status=status), + dict(brand='Savage', model_name='110 Tactical', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Savage', model_name='110 Elite Precision .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Savage', model_name='110 Elite Precision 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Savage', model_name='110 Long Range Hunter .300WM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.300 Win Mag', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Bergara', model_name='B-14 HMR .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=559, status=status), + dict(brand='Bergara', model_name='B-14 HMR 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=559, status=status), + dict(brand='Bergara', model_name='B-14 BMP', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Bergara', model_name='B-14 Wilderness Ridge 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=559, status=status), + dict(brand='Sako', model_name='85 Black Bear', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=572, status=status), + dict(brand='Sako', model_name='TRG 22 A1', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Sako', model_name='TRG 42 A1', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.338 Lapua Mag', action='BOLT', + barrel_length_mm=690, status=status), + dict(brand='Steyr Mannlicher', model_name='SSG 08 .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=600, status=status), + dict(brand='Steyr Mannlicher', model_name='SSG 08 .338', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.338 Lapua Mag', action='BOLT', + barrel_length_mm=690, status=status), + dict(brand='Accuracy International', model_name='AXMC .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Accuracy International', model_name='AXMC .338', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.338 Lapua Mag', action='BOLT', + barrel_length_mm=686, status=status), + dict(brand='Accuracy International', model_name='AW50', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.50 BMG', action='BOLT', + barrel_length_mm=686, status=status), + dict(brand='Barrett', model_name='MRAD .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Barrett', model_name='MRAD .338', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.338 Lapua Mag', action='BOLT', + barrel_length_mm=686, status=status), + dict(brand='Barrett', model_name='MRAD .300NM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.300 Norma Mag', action='BOLT', + barrel_length_mm=686, status=status), + dict(brand='Browning', model_name='X-Bolt Pro LR 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Browning', model_name='X-Bolt Pro LR .300WM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.300 Win Mag', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Ruger', model_name='Precision Rifle 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Ruger', model_name='Precision Rifle .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Ruger', model_name='Precision Rifle .300WM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.300 Win Mag', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='Blaser', model_name='R8 Professional .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=580, status=status), + dict(brand='Blaser', model_name='R8 Ultimate 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=580, status=status), + dict(brand='Mauser', model_name='M18 .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=560, status=status), + dict(brand='Mauser', model_name='M18 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=560, status=status), + dict(brand='Mauser', model_name='M18 .300WM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.300 Win Mag', action='BOLT', + barrel_length_mm=600, status=status), + dict(brand='Christensen Arms', model_name='Mesa 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=559, status=status), + dict(brand='Christensen Arms', model_name='MPR 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Howa', model_name='1500 Hogue .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Howa', model_name='1500 Mini-Action 6.5 Grendel', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Grendel', action='BOLT', + barrel_length_mm=510, status=status), + dict(brand='CZ', model_name='600 Range .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=status), + dict(brand='CZ', model_name='600 Alpha 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=560, status=status), + dict(brand='Winchester', model_name='Model 70 Super Grade .30-06', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.30-06 Springfield', action='BOLT', + barrel_length_mm=610, status=status), + dict(brand='Winchester', model_name='Model 70 Featherweight .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=559, status=status), + # ── Semi-auto rifles / carbines ──────────────────────────────────── + dict(brand='Colt', model_name='LE6920', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=30, status=status), + dict(brand='Daniel Defense', model_name='DDM4 V7', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=32, status=status), + dict(brand='Daniel Defense', model_name='DDM4 MK18', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=267, magazine_capacity=30, status=status), + dict(brand='FN Herstal', model_name='SCAR 16S', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=30, status=status), + dict(brand='FN Herstal', model_name='SCAR 17S', gear_type='FIREARM', + firearm_type='CARBINE', caliber='7.62x51mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=20, status=status), + dict(brand='Heckler & Koch', model_name='HK416 A5', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=30, status=status), + dict(brand='Heckler & Koch', model_name='MR223 A3', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=30, status=status), + dict(brand='BCM', model_name='RECCE-16 MCMR', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=30, status=status), + dict(brand='LWRC International', model_name='IC-A5', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=30, status=status), + dict(brand='SIG Sauer', model_name='MCX Spear', gear_type='FIREARM', + firearm_type='CARBINE', caliber='6.8x51mm', action='SEMI_AUTO', + barrel_length_mm=381, magazine_capacity=20, status=status), + dict(brand='SIG Sauer', model_name='MCX Rattler', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=165, magazine_capacity=30, status=status), + dict(brand='Aero Precision', model_name='M4E1', gear_type='FIREARM', + firearm_type='CARBINE', caliber='5.56x45mm NATO', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=30, status=status), + dict(brand='Ruger', model_name='PC Carbine 9mm', gear_type='FIREARM', + firearm_type='CARBINE', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=406, magazine_capacity=17, status=status), + # ── Pistols ────────────────────────────────────────────────────────── + dict(brand='Glock', model_name='G17 Gen5', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=114, magazine_capacity=17, status=status), + dict(brand='Glock', model_name='G19 Gen5', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=102, magazine_capacity=15, status=status), + dict(brand='Glock', model_name='G34 Gen5 MOS', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=135, magazine_capacity=17, status=status), + dict(brand='Glock', model_name='G21 Gen4', gear_type='FIREARM', + firearm_type='PISTOL', caliber='.45 ACP', action='SEMI_AUTO', + barrel_length_mm=114, magazine_capacity=13, status=status), + dict(brand='Glock', model_name='G22 Gen4', gear_type='FIREARM', + firearm_type='PISTOL', caliber='.40 S&W', action='SEMI_AUTO', + barrel_length_mm=114, magazine_capacity=15, status=status), + dict(brand='SIG Sauer', model_name='P320 M17', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=127, magazine_capacity=21, status=status), + dict(brand='SIG Sauer', model_name='P320 M18', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=114, magazine_capacity=21, status=status), + dict(brand='SIG Sauer', model_name='P226 Legion', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=112, magazine_capacity=15, status=status), + dict(brand='SIG Sauer', model_name='P320 X5 Legion', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=127, magazine_capacity=21, status=status), + dict(brand='Beretta', model_name='92FS', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=125, magazine_capacity=15, status=status), + dict(brand='Beretta', model_name='APX A1', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=108, magazine_capacity=17, status=status), + dict(brand='CZ', model_name='75 B', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=114, magazine_capacity=16, status=status), + dict(brand='CZ', model_name='Shadow 2', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=120, magazine_capacity=17, status=status), + dict(brand='CZ', model_name='P-10 C', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=102, magazine_capacity=15, status=status), + dict(brand='Walther', model_name='PPQ M2', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=102, magazine_capacity=15, status=status), + dict(brand='Walther', model_name='PDP Full-Size', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=114, magazine_capacity=18, status=status), + dict(brand='Heckler & Koch', model_name='VP9', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=108, magazine_capacity=15, status=status), + dict(brand='Heckler & Koch', model_name='USP .45', gear_type='FIREARM', + firearm_type='PISTOL', caliber='.45 ACP', action='SEMI_AUTO', + barrel_length_mm=108, magazine_capacity=12, status=status), + dict(brand='Smith & Wesson', model_name='M&P 9 M2.0', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=108, magazine_capacity=17, status=status), + dict(brand='Springfield Armory', model_name='XD-M Elite 5.25"', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9x19mm', action='SEMI_AUTO', + barrel_length_mm=133, magazine_capacity=22, status=status), + dict(brand='FN Herstal', model_name='FNX-45 Tactical', gear_type='FIREARM', + firearm_type='PISTOL', caliber='.45 ACP', action='SEMI_AUTO', + barrel_length_mm=127, magazine_capacity=15, status=status), + # ── Revolvers ───────────────────────────────────────────────────────── + dict(brand='Smith & Wesson', model_name='686 Plus', gear_type='FIREARM', + firearm_type='REVOLVER', caliber='.357 Magnum', action='SEMI_AUTO', + barrel_length_mm=152, magazine_capacity=7, status=status), + dict(brand='Smith & Wesson', model_name='629 Classic', gear_type='FIREARM', + firearm_type='REVOLVER', caliber='.44 Magnum', action='SEMI_AUTO', + barrel_length_mm=152, magazine_capacity=6, status=status), + dict(brand='Ruger', model_name='GP100', gear_type='FIREARM', + firearm_type='REVOLVER', caliber='.357 Magnum', action='SEMI_AUTO', + barrel_length_mm=102, magazine_capacity=6, status=status), + dict(brand='Ruger', model_name='Super Redhawk Alaskan', gear_type='FIREARM', + firearm_type='REVOLVER', caliber='.454 Casull', action='SEMI_AUTO', + barrel_length_mm=64, magazine_capacity=6, status=status), + dict(brand='Colt', model_name='Python 4.25"', gear_type='FIREARM', + firearm_type='REVOLVER', caliber='.357 Magnum', action='SEMI_AUTO', + barrel_length_mm=108, magazine_capacity=6, status=status), + # ── Shotguns ─────────────────────────────────────────────────────────── + dict(brand='Mossberg', model_name='500 ATI Tactical', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='PUMP', + barrel_length_mm=470, magazine_capacity=8, status=status), + dict(brand='Mossberg', model_name='590A1', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='PUMP', + barrel_length_mm=508, magazine_capacity=9, status=status), + dict(brand='Remington', model_name='870 Express', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='PUMP', + barrel_length_mm=711, magazine_capacity=4, status=status), + dict(brand='Remington', model_name='870 Tactical', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='PUMP', + barrel_length_mm=470, magazine_capacity=6, status=status), + dict(brand='Benelli', model_name='M2 Field', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='SEMI_AUTO', + barrel_length_mm=711, magazine_capacity=3, status=status), + dict(brand='Benelli', model_name='M4 Tactical', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='SEMI_AUTO', + barrel_length_mm=470, magazine_capacity=5, status=status), + dict(brand='Beretta', model_name='A400 Xtreme Plus', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='SEMI_AUTO', + barrel_length_mm=762, magazine_capacity=3, status=status), + dict(brand='Browning', model_name='Maxus II', gear_type='FIREARM', + firearm_type='SHOTGUN', caliber='12 Gauge', action='SEMI_AUTO', + barrel_length_mm=762, magazine_capacity=4, status=status), + ] + + for data in firearms: + Firearm.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + caliber=data['caliber'], + defaults=data, + ) + + +def populate_scopes(apps, schema_editor): + Scope = _get(apps, 'Scope') + status = _verified(apps) + + scopes = [ + dict(brand='Vortex', model_name='Razor HD Gen III 1-10x24', gear_type='SCOPE', + magnification_min=1, magnification_max=10, objective_diameter_mm=24, + tube_diameter_mm=34, reticle_type='ILLUMINATED', status=status), + dict(brand='Vortex', model_name='Razor HD Gen III 6-36x56', gear_type='SCOPE', + magnification_min=6, magnification_max=36, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Vortex', model_name='Viper PST Gen II 5-25x50', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Vortex', model_name='Viper PST Gen II 2-10x32', gear_type='SCOPE', + magnification_min=2, magnification_max=10, objective_diameter_mm=32, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Vortex', model_name='Strike Eagle 1-8x24', gear_type='SCOPE', + magnification_min=1, magnification_max=8, objective_diameter_mm=24, + tube_diameter_mm=30, reticle_type='ILLUMINATED', status=status), + dict(brand='Vortex', model_name='Strike Eagle 5-25x56', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='ETCHED_GLASS', status=status), + dict(brand='Vortex', model_name='Crossfire II 6-18x44', gear_type='SCOPE', + magnification_min=6, magnification_max=18, objective_diameter_mm=44, + tube_diameter_mm=25, reticle_type='BDC', status=status), + dict(brand='Vortex', model_name='Diamondback Tactical 6-24x50', gear_type='SCOPE', + magnification_min=6, magnification_max=24, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Vortex', model_name='Golden Eagle HD 15-60x52', gear_type='SCOPE', + magnification_min=15, magnification_max=60, objective_diameter_mm=52, + tube_diameter_mm=35, reticle_type='DUPLEX', status=status), + dict(brand='Leupold', model_name='Mark 5HD 5-25x56', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=35, reticle_type='MIL_DOT', status=status), + dict(brand='Leupold', model_name='Mark 5HD 3.6-18x44', gear_type='SCOPE', + magnification_min=4, magnification_max=18, objective_diameter_mm=44, + tube_diameter_mm=35, reticle_type='MIL_DOT', status=status), + dict(brand='Leupold', model_name='VX-6HD 3-18x50', gear_type='SCOPE', + magnification_min=3, magnification_max=18, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='ILLUMINATED', status=status), + dict(brand='Leupold', model_name='VX-6HD 4-24x52', gear_type='SCOPE', + magnification_min=4, magnification_max=24, objective_diameter_mm=52, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Leupold', model_name='Mark 3HD 4-12x40', gear_type='SCOPE', + magnification_min=4, magnification_max=12, objective_diameter_mm=40, + tube_diameter_mm=25, reticle_type='DUPLEX', status=status), + dict(brand='Leupold', model_name='Mark 4 LR/T 8.5-25x50', gear_type='SCOPE', + magnification_min=9, magnification_max=25, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Nightforce', model_name='ATACR 7-35x56 F1', gear_type='SCOPE', + magnification_min=7, magnification_max=35, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Nightforce', model_name='ATACR 5-25x56 F1', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Nightforce', model_name='ATACR 4-16x42 F1', gear_type='SCOPE', + magnification_min=4, magnification_max=16, objective_diameter_mm=42, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Nightforce', model_name='NXS 3.5-15x50', gear_type='SCOPE', + magnification_min=4, magnification_max=15, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Nightforce', model_name='NXS 8-32x56', gear_type='SCOPE', + magnification_min=8, magnification_max=32, objective_diameter_mm=56, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Nightforce', model_name='SHV 4-14x50', gear_type='SCOPE', + magnification_min=4, magnification_max=14, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Nightforce', model_name='SHV 3-10x42', gear_type='SCOPE', + magnification_min=3, magnification_max=10, objective_diameter_mm=42, + tube_diameter_mm=30, reticle_type='DUPLEX', status=status), + dict(brand='Schmidt & Bender', model_name='PM II 5-25x56', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Schmidt & Bender', model_name='PM II 3-20x50', gear_type='SCOPE', + magnification_min=3, magnification_max=20, objective_diameter_mm=50, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Schmidt & Bender', model_name='PM II 4-16x42', gear_type='SCOPE', + magnification_min=4, magnification_max=16, objective_diameter_mm=42, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Schmidt & Bender', model_name='Polar T96 6-36x56', gear_type='SCOPE', + magnification_min=6, magnification_max=36, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='ETCHED_GLASS', status=status), + dict(brand='Swarovski', model_name='Z8i 2.3-18x56', gear_type='SCOPE', + magnification_min=2, magnification_max=18, objective_diameter_mm=56, + tube_diameter_mm=30, reticle_type='ILLUMINATED', status=status), + dict(brand='Swarovski', model_name='Z8i 3.5-28x50', gear_type='SCOPE', + magnification_min=4, magnification_max=28, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='ILLUMINATED', status=status), + dict(brand='Swarovski', model_name='Z5i 3.5-18x44', gear_type='SCOPE', + magnification_min=4, magnification_max=18, objective_diameter_mm=44, + tube_diameter_mm=25, reticle_type='ILLUMINATED', status=status), + dict(brand='Zeiss', model_name='Victory V8 4.8-35x60', gear_type='SCOPE', + magnification_min=5, magnification_max=35, objective_diameter_mm=60, + tube_diameter_mm=36, reticle_type='ILLUMINATED', status=status), + dict(brand='Zeiss', model_name='Conquest V4 6-24x50', gear_type='SCOPE', + magnification_min=6, magnification_max=24, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Zeiss', model_name='LRP S5 6-36x56', gear_type='SCOPE', + magnification_min=6, magnification_max=36, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='March', model_name='FX 5-40x56', gear_type='SCOPE', + magnification_min=5, magnification_max=40, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='March', model_name='FX 8-80x56', gear_type='SCOPE', + magnification_min=8, magnification_max=80, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='ETCHED_GLASS', status=status), + dict(brand='Bushnell', model_name='Elite Tactical DMR3 3.5-21x50', gear_type='SCOPE', + magnification_min=4, magnification_max=21, objective_diameter_mm=50, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Bushnell', model_name='Elite Tactical XRS3 6-36x56', gear_type='SCOPE', + magnification_min=6, magnification_max=36, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Bushnell', model_name='Prime 3-12x40', gear_type='SCOPE', + magnification_min=3, magnification_max=12, objective_diameter_mm=40, + tube_diameter_mm=25, reticle_type='BDC', status=status), + dict(brand='Athlon', model_name='Argos BTR Gen2 6-24x50', gear_type='SCOPE', + magnification_min=6, magnification_max=24, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Athlon', model_name='Cronus BTR Gen2 4.5-29x56', gear_type='SCOPE', + magnification_min=5, magnification_max=29, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Kahles', model_name='K525i 5-25x56', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Kahles', model_name='K318i 3.5-18x50', gear_type='SCOPE', + magnification_min=4, magnification_max=18, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MIL_DOT', status=status), + dict(brand='Steiner', model_name='T6Xi 5-30x56', gear_type='SCOPE', + magnification_min=5, magnification_max=30, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + dict(brand='Steiner', model_name='T5Xi 3-15x50', gear_type='SCOPE', + magnification_min=3, magnification_max=15, objective_diameter_mm=50, + tube_diameter_mm=34, reticle_type='MIL_DOT', status=status), + ] + + for data in scopes: + Scope.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + defaults=data, + ) + + +def populate_suppressors(apps, schema_editor): + Suppressor = _get(apps, 'Suppressor') + status = _verified(apps) + + suppressors = [ + dict(brand='SilencerCo', model_name='Omega 300', gear_type='SUPPRESSOR', + max_caliber='.300 Win Mag', thread_pitch='5/8-24', + length_mm=178, weight_g=454, status=status), + dict(brand='SilencerCo', model_name='Saker ASR 762', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='ASR', + length_mm=203, weight_g=538, status=status), + dict(brand='SilencerCo', model_name='Harvester Big Bore', gear_type='SUPPRESSOR', + max_caliber='.338 Lapua Mag', thread_pitch='5/8-24', + length_mm=229, weight_g=596, status=status), + dict(brand='SilencerCo', model_name='Octane 9', gear_type='SUPPRESSOR', + max_caliber='9mm', thread_pitch='1/2-28', + length_mm=203, weight_g=340, status=status), + dict(brand='SilencerCo', model_name='Hybrid 46M', gear_type='SUPPRESSOR', + max_caliber='.458 SOCOM', thread_pitch='5/8-24', + length_mm=210, weight_g=680, status=status), + dict(brand='Dead Air', model_name='Sandman-S', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='KeyMo', + length_mm=178, weight_g=540, status=status), + dict(brand='Dead Air', model_name='Sandman-L', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='KeyMo', + length_mm=229, weight_g=680, status=status), + dict(brand='Dead Air', model_name='Nomad-L', gear_type='SUPPRESSOR', + max_caliber='.300 Win Mag', thread_pitch='5/8-24', + length_mm=222, weight_g=553, status=status), + dict(brand='Dead Air', model_name='Ghost 45M', gear_type='SUPPRESSOR', + max_caliber='.45 ACP', thread_pitch='5/8-24', + length_mm=203, weight_g=454, status=status), + dict(brand='Thunderbeast', model_name='Ultra 7', gear_type='SUPPRESSOR', + max_caliber='.308 Win', thread_pitch='5/8-24', + length_mm=178, weight_g=380, status=status), + dict(brand='Thunderbeast', model_name='Ultra 9', gear_type='SUPPRESSOR', + max_caliber='.300 Win Mag', thread_pitch='5/8-24', + length_mm=229, weight_g=453, status=status), + dict(brand='Rugged', model_name='Obsidian45', gear_type='SUPPRESSOR', + max_caliber='.45 ACP', thread_pitch='5/8-24', + length_mm=190, weight_g=369, status=status), + dict(brand='Rugged', model_name='Surge 762', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='5/8-24', + length_mm=191, weight_g=529, status=status), + dict(brand='Ase Utra', model_name='S-series S20', gear_type='SUPPRESSOR', + max_caliber='.308 Win', thread_pitch='M15x1', + length_mm=200, weight_g=410, status=status), + dict(brand='Ase Utra', model_name='Radien', gear_type='SUPPRESSOR', + max_caliber='.338 Lapua Mag', thread_pitch='M18x1.5', + length_mm=235, weight_g=595, status=status), + dict(brand='Hausken', model_name='JD224 Whisper', gear_type='SUPPRESSOR', + max_caliber='.308 Win', thread_pitch='M14x1', + length_mm=218, weight_g=440, status=status), + dict(brand='Surefire', model_name='SOCOM762-RC2', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='SOCOM', + length_mm=203, weight_g=680, status=status), + dict(brand='Gemtech', model_name='Sandstorm', gear_type='SUPPRESSOR', + max_caliber='.300 Win Mag', thread_pitch='5/8-24', + length_mm=210, weight_g=567, status=status), + ] + + for data in suppressors: + Suppressor.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + defaults=data, + ) + + +def populate_bipods(apps, schema_editor): + Bipod = _get(apps, 'Bipod') + status = _verified(apps) + + bipods = [ + dict(brand='Harris', model_name='S-BRM 6-9"', gear_type='BIPOD', + min_height_mm=152, max_height_mm=229, + attachment_type='SLING_STUD', status=status), + dict(brand='Harris', model_name='BRMS 9-13"', gear_type='BIPOD', + min_height_mm=229, max_height_mm=330, + attachment_type='SLING_STUD', status=status), + dict(brand='Harris', model_name='S-L 9-13" Notched', gear_type='BIPOD', + min_height_mm=229, max_height_mm=330, + attachment_type='PICATINNY', status=status), + dict(brand='Atlas', model_name='BT10 V8 5H', gear_type='BIPOD', + min_height_mm=145, max_height_mm=229, + attachment_type='PICATINNY', status=status), + dict(brand='Atlas', model_name='BT47 AccuShot 5H MLOK', gear_type='BIPOD', + min_height_mm=145, max_height_mm=229, + attachment_type='MLOK', status=status), + dict(brand='Magpul', model_name='Bipod MLOK', gear_type='BIPOD', + min_height_mm=180, max_height_mm=267, + attachment_type='MLOK', status=status), + dict(brand='Magpul', model_name='Bipod Picatinny', gear_type='BIPOD', + min_height_mm=180, max_height_mm=267, + attachment_type='PICATINNY', status=status), + dict(brand='Accu-Shot', model_name='PSSS-11 Stalker', gear_type='BIPOD', + min_height_mm=127, max_height_mm=279, + attachment_type='PICATINNY', status=status), + dict(brand='MDT', model_name='Fieldsport Bipod', gear_type='BIPOD', + min_height_mm=152, max_height_mm=267, + attachment_type='ARCA_SWISS', status=status), + dict(brand='Area 419', model_name='Arcalock Bipod 6-9"', gear_type='BIPOD', + min_height_mm=152, max_height_mm=229, + attachment_type='ARCA_SWISS', status=status), + ] + + for data in bipods: + Bipod.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + defaults=data, + ) + + +def populate_magazines(apps, schema_editor): + Magazine = _get(apps, 'Magazine') + status = _verified(apps) + + magazines = [ + dict(brand='Magpul', model_name='PMAG 30 AR/M4 Gen M3', gear_type='MAGAZINE', + caliber='5.56x45mm NATO', capacity=30, status=status), + dict(brand='Magpul', model_name='PMAG 20 AR/M4 Gen M3', gear_type='MAGAZINE', + caliber='5.56x45mm NATO', capacity=20, status=status), + dict(brand='Magpul', model_name='PMAG 10 AR/M4 Gen M3', gear_type='MAGAZINE', + caliber='5.56x45mm NATO', capacity=10, status=status), + dict(brand='Magpul', model_name='PMAG 20 SR/LR Gen M3', gear_type='MAGAZINE', + caliber='.308 Win', capacity=20, status=status), + dict(brand='Magpul', model_name='PMAG 10 SR/LR Gen M3', gear_type='MAGAZINE', + caliber='.308 Win', capacity=10, status=status), + dict(brand='Accuracy International', model_name='AICS 10-rd .308', gear_type='MAGAZINE', + caliber='.308 Win', capacity=10, status=status), + dict(brand='Accuracy International', model_name='AICS 5-rd .308', gear_type='MAGAZINE', + caliber='.308 Win', capacity=5, status=status), + dict(brand='Accuracy International', model_name='AICS 10-rd .338 Lapua', gear_type='MAGAZINE', + caliber='.338 Lapua Mag', capacity=10, status=status), + dict(brand='Accuracy International', model_name='AICS 5-rd .338 Lapua', gear_type='MAGAZINE', + caliber='.338 Lapua Mag', capacity=5, status=status), + dict(brand='Glock', model_name='OEM 9mm 17-rd', gear_type='MAGAZINE', + caliber='9x19mm', capacity=17, status=status), + dict(brand='Glock', model_name='OEM 9mm 15-rd', gear_type='MAGAZINE', + caliber='9x19mm', capacity=15, status=status), + dict(brand='Glock', model_name='OEM 9mm 33-rd', gear_type='MAGAZINE', + caliber='9x19mm', capacity=33, status=status), + dict(brand='Glock', model_name='OEM .40 S&W 15-rd', gear_type='MAGAZINE', + caliber='.40 S&W', capacity=15, status=status), + dict(brand='SIG Sauer', model_name='P320/P250 9mm 21-rd', gear_type='MAGAZINE', + caliber='9x19mm', capacity=21, status=status), + dict(brand='SIG Sauer', model_name='P226 9mm 15-rd', gear_type='MAGAZINE', + caliber='9x19mm', capacity=15, status=status), + dict(brand='CZ', model_name='CZ 75 9mm 16-rd', gear_type='MAGAZINE', + caliber='9x19mm', capacity=16, status=status), + dict(brand='CZ', model_name='CZ Shadow 2 9mm 17-rd', gear_type='MAGAZINE', + caliber='9x19mm', capacity=17, status=status), + dict(brand='Tikka', model_name='T3 .308 5-rd', gear_type='MAGAZINE', + caliber='.308 Win', capacity=5, status=status), + dict(brand='Tikka', model_name='T3 6.5 Creedmoor 5-rd', gear_type='MAGAZINE', + caliber='6.5 Creedmoor', capacity=5, status=status), + ] + + for data in magazines: + Magazine.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + caliber=data.get('caliber', ''), + defaults=data, + ) + + +def populate_ammo(apps, schema_editor): + Ammo = _get(apps, 'Ammo') + status = _verified(apps) + + ammo_list = [ + # ── .308 Win ────────────────────────────────────────────────────────── + dict(brand='Federal', name='Gold Medal Match 175gr', caliber='.308 Win', + bullet_weight_gr='175', bullet_type='SMK', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2600, box_count=20, status=status), + dict(brand='Federal', name='Gold Medal Match 168gr', caliber='.308 Win', + bullet_weight_gr='168', bullet_type='SMK', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2650, box_count=20, status=status), + dict(brand='Hornady', name='ELD Match 168gr', caliber='.308 Win', + bullet_weight_gr='168', bullet_type='HPBT', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2710, box_count=20, status=status), + dict(brand='Hornady', name='ELD Match 178gr', caliber='.308 Win', + bullet_weight_gr='178', bullet_type='HPBT', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2600, box_count=20, status=status), + dict(brand='Lapua', name='Scenar-L OT 175gr', caliber='.308 Win', + bullet_weight_gr='175', bullet_type='HPBT', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2575, box_count=50, status=status), + dict(brand='Lapua', name='Scenar-L OT 155gr', caliber='.308 Win', + bullet_weight_gr='155', bullet_type='HPBT', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2953, box_count=50, status=status), + dict(brand='Berger', name='Hybrid OTM Tactical 185gr', caliber='.308 Win', + bullet_weight_gr='185', bullet_type='HPBT', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2600, box_count=20, status=status), + dict(brand='Black Hills', name='Match 175gr SMK', caliber='.308 Win', + bullet_weight_gr='175', bullet_type='SMK', + primer_size='LR', case_material='BRASS', + muzzle_velocity_fps=2600, box_count=20, status=status), + # ── 6.5 Creedmoor ───────────────────────────────────────────────────── + dict(brand='Federal', name='Gold Medal Match 140gr SMK', caliber='6.5 Creedmoor', + bullet_weight_gr='140', bullet_type='SMK', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2710, box_count=20, status=status), + dict(brand='Hornady', name='ELD Match 147gr', caliber='6.5 Creedmoor', + bullet_weight_gr='147', bullet_type='HPBT', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2695, box_count=20, status=status), + dict(brand='Hornady', name='ELD Match 140gr', caliber='6.5 Creedmoor', + bullet_weight_gr='140', bullet_type='HPBT', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2710, box_count=20, status=status), + dict(brand='Lapua', name='Scenar-L OT 136gr', caliber='6.5 Creedmoor', + bullet_weight_gr='136', bullet_type='HPBT', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2822, box_count=50, status=status), + dict(brand='Berger', name='Hybrid OTM 130gr', caliber='6.5 Creedmoor', + bullet_weight_gr='130', bullet_type='HPBT', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2875, box_count=20, status=status), + dict(brand='Nosler', name='RDF 140gr', caliber='6.5 Creedmoor', + bullet_weight_gr='140', bullet_type='HPBT', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2700, box_count=20, status=status), + # ── .300 Win Mag ────────────────────────────────────────────────────── + dict(brand='Federal', name='Gold Medal Match 215gr A-Tip', caliber='.300 Win Mag', + bullet_weight_gr='215', bullet_type='A_TIP', + primer_size='LRM', case_material='BRASS', + muzzle_velocity_fps=2830, box_count=20, status=status), + dict(brand='Hornady', name='ELD Match 195gr', caliber='.300 Win Mag', + bullet_weight_gr='195', bullet_type='HPBT', + primer_size='LRM', case_material='BRASS', + muzzle_velocity_fps=2930, box_count=20, status=status), + dict(brand='Berger', name='Hybrid OTM 215gr', caliber='.300 Win Mag', + bullet_weight_gr='215', bullet_type='HPBT', + primer_size='LRM', case_material='BRASS', + muzzle_velocity_fps=2770, box_count=20, status=status), + # ── .338 Lapua Mag ──────────────────────────────────────────────────── + dict(brand='Lapua', name='Scenar-L OT 250gr', caliber='.338 Lapua Mag', + bullet_weight_gr='250', bullet_type='HPBT', + primer_size='LRM', case_material='BRASS', + muzzle_velocity_fps=2870, box_count=10, status=status), + dict(brand='Federal', name='Gold Medal Berger 300gr', caliber='.338 Lapua Mag', + bullet_weight_gr='300', bullet_type='HPBT', + primer_size='LRM', case_material='BRASS', + muzzle_velocity_fps=2650, box_count=20, status=status), + dict(brand='Hornady', name='Precision Hunter 270gr', caliber='.338 Lapua Mag', + bullet_weight_gr='270', bullet_type='HPBT', + primer_size='LRM', case_material='BRASS', + muzzle_velocity_fps=2745, box_count=20, status=status), + # ── 5.56x45mm NATO ──────────────────────────────────────────────────── + dict(brand='Federal', name='XM193 55gr', caliber='5.56x45mm NATO', + bullet_weight_gr='55', bullet_type='FMJ', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=3165, box_count=20, status=status), + dict(brand='Federal', name='XM855 62gr', caliber='5.56x45mm NATO', + bullet_weight_gr='62', bullet_type='FMJ', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=3020, box_count=20, status=status), + dict(brand='Hornady', name='TAP FPD 75gr', caliber='5.56x45mm NATO', + bullet_weight_gr='75', bullet_type='HP', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2790, box_count=20, status=status), + dict(brand='Black Hills', name='77gr OTM', caliber='5.56x45mm NATO', + bullet_weight_gr='77', bullet_type='HPBT', + primer_size='SR', case_material='BRASS', + muzzle_velocity_fps=2750, box_count=50, status=status), + # ── 9x19mm ──────────────────────────────────────────────────────────── + dict(brand='Federal', name='HST 124gr', caliber='9x19mm', + bullet_weight_gr='124', bullet_type='HP', + primer_size='SP', case_material='BRASS', + muzzle_velocity_fps=1150, box_count=20, status=status), + dict(brand='Federal', name='HST 147gr', caliber='9x19mm', + bullet_weight_gr='147', bullet_type='HP', + primer_size='SP', case_material='BRASS', + muzzle_velocity_fps=1000, box_count=20, status=status), + dict(brand='Speer', name='Gold Dot 124gr', caliber='9x19mm', + bullet_weight_gr='124', bullet_type='HP', + primer_size='SP', case_material='BRASS', + muzzle_velocity_fps=1150, box_count=20, status=status), + dict(brand='Hornady', name='Critical Defense 115gr', caliber='9x19mm', + bullet_weight_gr='115', bullet_type='HP', + primer_size='SP', case_material='BRASS', + muzzle_velocity_fps=1140, box_count=25, status=status), + dict(brand='Winchester', name='PDX1 Defender 124gr', caliber='9x19mm', + bullet_weight_gr='124', bullet_type='HP', + primer_size='SP', case_material='BRASS', + muzzle_velocity_fps=1175, box_count=20, status=status), + dict(brand='Federal', name='American Eagle FMJ 115gr', caliber='9x19mm', + bullet_weight_gr='115', bullet_type='FMJ', + primer_size='SP', case_material='BRASS', + muzzle_velocity_fps=1180, box_count=50, status=status), + dict(brand='Federal', name='American Eagle FMJ 124gr', caliber='9x19mm', + bullet_weight_gr='124', bullet_type='FMJ', + primer_size='SP', case_material='BRASS', + muzzle_velocity_fps=1150, box_count=50, status=status), + # ── .45 ACP ─────────────────────────────────────────────────────────── + dict(brand='Federal', name='HST 230gr', caliber='.45 ACP', + bullet_weight_gr='230', bullet_type='HP', + primer_size='LP', case_material='BRASS', + muzzle_velocity_fps=890, box_count=20, status=status), + dict(brand='Speer', name='Gold Dot 230gr', caliber='.45 ACP', + bullet_weight_gr='230', bullet_type='HP', + primer_size='LP', case_material='BRASS', + muzzle_velocity_fps=890, box_count=20, status=status), + dict(brand='Hornady', name='Critical Duty 220gr', caliber='.45 ACP', + bullet_weight_gr='220', bullet_type='HP', + primer_size='LP', case_material='BRASS', + muzzle_velocity_fps=975, box_count=20, status=status), + # ── 12 Gauge ────────────────────────────────────────────────────────── + dict(brand='Federal', name='Premium 00 Buck', caliber='12 Gauge', + bullet_weight_gr='486', bullet_type='SP', + case_material='BRASS', + muzzle_velocity_fps=1325, box_count=5, status=status), + dict(brand='Hornady', name='Critical Defense 00 Buck', caliber='12 Gauge', + bullet_weight_gr='486', bullet_type='SP', + case_material='BRASS', + muzzle_velocity_fps=1600, box_count=10, status=status), + dict(brand='Winchester', name='Super-X Rifled Slug', caliber='12 Gauge', + bullet_weight_gr='437', bullet_type='SP', + case_material='BRASS', + muzzle_velocity_fps=1600, box_count=5, status=status), + ] + + for data in ammo_list: + Ammo.objects.get_or_create( + brand=data['brand'], + name=data['name'], + caliber=data['caliber'], + defaults=data, + ) + + +def populate_reloading_components(apps, schema_editor): + Primer = _get(apps, 'Primer') + Brass = _get(apps, 'Brass') + Bullet = _get(apps, 'Bullet') + Powder = _get(apps, 'Powder') + + # ── Primers ─────────────────────────────────────────────────────────────── + primers = [ + dict(brand='CCI', name='200 Large Rifle', size='LR'), + dict(brand='CCI', name='250 Large Rifle Magnum', size='LRM'), + dict(brand='CCI', name='400 Small Rifle', size='SR'), + dict(brand='CCI', name='450 Small Rifle Magnum', size='SR'), + dict(brand='CCI', name='500 Small Pistol', size='SP'), + dict(brand='CCI', name='550 Small Pistol Magnum', size='SP'), + dict(brand='CCI', name='300 Large Pistol', size='LP'), + dict(brand='CCI', name='350 Large Pistol Magnum', size='LP'), + dict(brand='Federal', name='210 Large Rifle', size='LR'), + dict(brand='Federal', name='210M Large Rifle Match', size='LR'), + dict(brand='Federal', name='215M Large Rifle Magnum Match', size='LRM'), + dict(brand='Federal', name='205M Small Rifle Match', size='SR'), + dict(brand='Federal', name='100 Small Pistol', size='SP'), + dict(brand='Federal', name='150 Large Pistol', size='LP'), + dict(brand='Winchester', name='WLR Large Rifle', size='LR'), + dict(brand='Winchester', name='WLRM Large Rifle Magnum', size='LRM'), + dict(brand='Winchester', name='WSR Small Rifle', size='SR'), + dict(brand='Winchester', name='WSP Small Pistol', size='SP'), + dict(brand='Winchester', name='WLP Large Pistol', size='LP'), + dict(brand='Remington', name='9-1/2 Large Rifle', size='LR'), + dict(brand='Remington', name='9-1/2M Large Rifle Magnum', size='LRM'), + dict(brand='Remington', name='6-1/2 Small Rifle', size='SR'), + dict(brand='Remington', name='1-1/2 Small Pistol', size='SP'), + dict(brand='Lapua', name='Large Rifle', size='LR'), + dict(brand='Lapua', name='Small Rifle', size='SR'), + dict(brand='Murom', name='KVB-7.62 Large Rifle', size='LR'), + dict(brand='Fiocchi', name='Small Rifle', size='SR'), + dict(brand='Fiocchi', name='Large Rifle', size='LR'), + ] + + for data in primers: + Primer.objects.get_or_create(brand=data['brand'], name=data['name'], defaults=data) + + # ── Brass ───────────────────────────────────────────────────────────────── + brass_list = [ + dict(brand='Lapua', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + dict(brand='Lapua', caliber='6.5 Creedmoor', primer_pocket='SR', trim_length_mm='47.50'), + dict(brand='Lapua', caliber='.338 Lapua Mag', primer_pocket='LRM', trim_length_mm='68.50'), + dict(brand='Lapua', caliber='.30-06 Springfield', primer_pocket='LR', trim_length_mm='62.90'), + dict(brand='Peterson', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + dict(brand='Peterson', caliber='6.5 Creedmoor', primer_pocket='SR', trim_length_mm='47.50'), + dict(brand='Peterson', caliber='.300 Win Mag', primer_pocket='LRM', trim_length_mm='64.00'), + dict(brand='Peterson', caliber='6mm Creedmoor', primer_pocket='SR', trim_length_mm='47.50'), + dict(brand='Starline', caliber='9x19mm', primer_pocket='SP', trim_length_mm='19.15'), + dict(brand='Starline', caliber='.45 ACP', primer_pocket='LP', trim_length_mm='22.86'), + dict(brand='Starline', caliber='6.5 Creedmoor', primer_pocket='SR', trim_length_mm='47.50'), + dict(brand='Starline', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + dict(brand='Nosler', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + dict(brand='Nosler', caliber='6.5 Creedmoor', primer_pocket='SR', trim_length_mm='47.50'), + dict(brand='Federal', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + dict(brand='Federal', caliber='.30-06 Springfield', primer_pocket='LR', trim_length_mm='62.90'), + dict(brand='Winchester', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + dict(brand='Winchester', caliber='9x19mm', primer_pocket='SP', trim_length_mm='19.15'), + dict(brand='Remington', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + dict(brand='Hornady', caliber='6.5 Creedmoor', primer_pocket='SR', trim_length_mm='47.50'), + dict(brand='Alpha Munitions', caliber='6.5 Creedmoor', primer_pocket='SR', trim_length_mm='47.50'), + dict(brand='Alpha Munitions', caliber='.308 Win', primer_pocket='LR', trim_length_mm='51.18'), + ] + + for data in brass_list: + Brass.objects.get_or_create(brand=data['brand'], caliber=data['caliber'], defaults=data) + + # ── Bullets ─────────────────────────────────────────────────────────────── + bullets = [ + dict(brand='Sierra', model_name='MatchKing 175gr HPBT .30 cal', weight_gr='175', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.505', bc_g7='0.259'), + dict(brand='Sierra', model_name='MatchKing 168gr HPBT .30 cal', weight_gr='168', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.475', bc_g7='0.243'), + dict(brand='Sierra', model_name='MatchKing 155gr HPBT .30 cal', weight_gr='155', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.447', bc_g7='0.227'), + dict(brand='Sierra', model_name='MatchKing 185gr HPBT .30 cal', weight_gr='185', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.544', bc_g7='0.279'), + dict(brand='Sierra', model_name='MatchKing 140gr HPBT 6.5mm', weight_gr='140', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.535', bc_g7='0.267'), + dict(brand='Sierra', model_name='Tipped MatchKing 175gr .30 cal', weight_gr='175', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.530', bc_g7='0.271'), + dict(brand='Sierra', model_name='Tipped MatchKing 140gr 6.5mm', weight_gr='140', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.562', bc_g7='0.289'), + dict(brand='Sierra', model_name='MatchKing 300gr HPBT .338 cal', weight_gr='300', + bullet_type='HPBT', diameter_mm='8.58', bc_g1='0.768', bc_g7='0.388'), + dict(brand='Hornady', model_name='ELD-M 168gr .30 cal', weight_gr='168', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.475', bc_g7='0.243'), + dict(brand='Hornady', model_name='ELD-M 178gr .30 cal', weight_gr='178', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.536', bc_g7='0.274'), + dict(brand='Hornady', model_name='ELD-M 147gr 6.5mm', weight_gr='147', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.697', bc_g7='0.353'), + dict(brand='Hornady', model_name='ELD-M 140gr 6.5mm', weight_gr='140', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.646', bc_g7='0.326'), + dict(brand='Hornady', model_name='A-Tip 230gr .30 cal', weight_gr='230', + bullet_type='A_TIP', diameter_mm='7.82', bc_g1='0.818', bc_g7='0.414'), + dict(brand='Hornady', model_name='A-Tip 153.5gr 6.5mm', weight_gr='153.5', + bullet_type='A_TIP', diameter_mm='6.71', bc_g1='0.756', bc_g7='0.381'), + dict(brand='Hornady', model_name='HAP 124gr 9mm', weight_gr='124', + bullet_type='HPBT', diameter_mm='9.02'), + dict(brand='Berger', model_name='Hybrid OTM Tactical 185gr .30 cal', weight_gr='185', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.566', bc_g7='0.289'), + dict(brand='Berger', model_name='Hybrid OTM Tactical 215gr .30 cal', weight_gr='215', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.730', bc_g7='0.370'), + dict(brand='Berger', model_name='Hybrid OTM 130gr 6.5mm', weight_gr='130', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.564', bc_g7='0.279'), + dict(brand='Berger', model_name='Hybrid OTM 140gr 6.5mm', weight_gr='140', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.620', bc_g7='0.315'), + dict(brand='Berger', model_name='VLD Target 168gr .30 cal', weight_gr='168', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.500', bc_g7='0.252'), + dict(brand='Lapua', model_name='Scenar-L 155gr .30 cal', weight_gr='155', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.508', bc_g7='0.258'), + dict(brand='Lapua', model_name='Scenar-L 175gr .30 cal', weight_gr='175', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.528', bc_g7='0.268'), + dict(brand='Lapua', model_name='Scenar-L 136gr 6.5mm', weight_gr='136', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.547', bc_g7='0.276'), + dict(brand='Lapua', model_name='Scenar-L 250gr .338 cal', weight_gr='250', + bullet_type='HPBT', diameter_mm='8.58', bc_g1='0.683', bc_g7='0.344'), + dict(brand='Nosler', model_name='RDF 140gr 6.5mm', weight_gr='140', + bullet_type='HPBT', diameter_mm='6.71', bc_g1='0.610', bc_g7='0.310'), + dict(brand='Nosler', model_name='RDF 168gr .30 cal', weight_gr='168', + bullet_type='HPBT', diameter_mm='7.82', bc_g1='0.534', bc_g7='0.271'), + dict(brand='Speer', model_name='Gold Dot 124gr 9mm HP', weight_gr='124', + bullet_type='HP', diameter_mm='9.02'), + dict(brand='Berry', model_name='Plated RN 124gr 9mm', weight_gr='124', + bullet_type='FMJ', diameter_mm='9.02'), + ] + + for data in bullets: + Bullet.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + weight_gr=data['weight_gr'], + defaults=data, + ) + + # ── Powders ─────────────────────────────────────────────────────────────── + powders = [ + dict(brand='Hodgdon', name='Varget', powder_type='EXTRUDED', burn_rate_index=160), + dict(brand='Hodgdon', name='H4350', powder_type='EXTRUDED', burn_rate_index=200), + dict(brand='Hodgdon', name='H4895', powder_type='EXTRUDED', burn_rate_index=145), + dict(brand='Hodgdon', name='H1000', powder_type='EXTRUDED', burn_rate_index=250), + dict(brand='Hodgdon', name='Retumbo', powder_type='EXTRUDED', burn_rate_index=280), + dict(brand='Hodgdon', name='H380', powder_type='BALL', burn_rate_index=170), + dict(brand='Hodgdon', name='CFE 223', powder_type='BALL', burn_rate_index=130), + dict(brand='Hodgdon', name='CFE Pistol', powder_type='BALL', burn_rate_index=60), + dict(brand='Hodgdon', name='Titegroup', powder_type='BALL', burn_rate_index=30), + dict(brand='Hodgdon', name='HS-6', powder_type='BALL', burn_rate_index=70), + dict(brand='Hodgdon', name='Lil Gun', powder_type='BALL', burn_rate_index=75), + dict(brand='IMR', name='4064', powder_type='EXTRUDED', burn_rate_index=155), + dict(brand='IMR', name='4350', powder_type='EXTRUDED', burn_rate_index=198), + dict(brand='IMR', name='4166 Enduron', powder_type='EXTRUDED', burn_rate_index=162), + dict(brand='IMR', name='7977', powder_type='EXTRUDED', burn_rate_index=270), + dict(brand='IMR', name='4451 Enduron', powder_type='EXTRUDED', burn_rate_index=205), + dict(brand='IMR', name='4955', powder_type='EXTRUDED', burn_rate_index=230), + dict(brand='Vihtavuori', name='N140', powder_type='EXTRUDED', burn_rate_index=152), + dict(brand='Vihtavuori', name='N150', powder_type='EXTRUDED', burn_rate_index=185), + dict(brand='Vihtavuori', name='N160', powder_type='EXTRUDED', burn_rate_index=230), + dict(brand='Vihtavuori', name='N165', powder_type='EXTRUDED', burn_rate_index=255), + dict(brand='Vihtavuori', name='N170', powder_type='EXTRUDED', burn_rate_index=280), + dict(brand='Vihtavuori', name='N540', powder_type='BALL', burn_rate_index=130), + dict(brand='Vihtavuori', name='N560', powder_type='BALL', burn_rate_index=215), + dict(brand='Vihtavuori', name='3N37', powder_type='BALL', burn_rate_index=65), + dict(brand='Vihtavuori', name='N320', powder_type='FLAKE', burn_rate_index=40), + dict(brand='Vihtavuori', name='N330', powder_type='BALL', burn_rate_index=50), + dict(brand='Alliant', name='Reloder 16', powder_type='EXTRUDED', burn_rate_index=170), + dict(brand='Alliant', name='Reloder 26', powder_type='EXTRUDED', burn_rate_index=240), + dict(brand='Alliant', name='Reloder 33', powder_type='EXTRUDED', burn_rate_index=300), + dict(brand='Alliant', name='Reloder 17', powder_type='EXTRUDED', burn_rate_index=195), + dict(brand='Alliant', name='AR-Comp', powder_type='BALL', burn_rate_index=125), + dict(brand='Alliant', name='Power Pistol', powder_type='FLAKE', burn_rate_index=75), + dict(brand='Alliant', name='Unique', powder_type='FLAKE', burn_rate_index=55), + dict(brand='Norma', name='MRP', powder_type='EXTRUDED', burn_rate_index=235), + dict(brand='Norma', name='203-B', powder_type='EXTRUDED', burn_rate_index=175), + dict(brand='Norma', name='R1', powder_type='BALL', burn_rate_index=190), + dict(brand='Norma', name='URP', powder_type='EXTRUDED', burn_rate_index=195), + dict(brand='Accurate', name='2230', powder_type='BALL', burn_rate_index=120), + dict(brand='Accurate', name='2520', powder_type='BALL', burn_rate_index=148), + dict(brand='Accurate', name='4350', powder_type='BALL', burn_rate_index=197), + dict(brand='Accurate', name='LT-32', powder_type='BALL', burn_rate_index=135), + dict(brand='Winchester', name='748', powder_type='BALL', burn_rate_index=143), + dict(brand='Winchester', name='760', powder_type='BALL', burn_rate_index=186), + dict(brand='Winchester', name='AutoComp', powder_type='BALL', burn_rate_index=55), + dict(brand='Ramshot', name='Hunter', powder_type='BALL', burn_rate_index=212), + dict(brand='Ramshot', name='Magnum', powder_type='BALL', burn_rate_index=265), + dict(brand='Ramshot', name='TAC', powder_type='BALL', burn_rate_index=135), + ] + + for data in powders: + Powder.objects.get_or_create(brand=data['brand'], name=data['name'], defaults=data) + + +def populate_all(apps, schema_editor): + populate_firearms(apps, schema_editor) + populate_scopes(apps, schema_editor) + populate_suppressors(apps, schema_editor) + populate_bipods(apps, schema_editor) + populate_magazines(apps, schema_editor) + populate_ammo(apps, schema_editor) + populate_reloading_components(apps, schema_editor) + + +def reverse_noop(apps, schema_editor): + """Catalog data is safe to leave behind on reversal.""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0003_ammo_brass_bullet_powder_primer_alter_bipod_options_and_more'), + ] + + operations = [ + migrations.RunPython(populate_all, reverse_noop), + ] diff --git a/apps/gears/migrations/0005_rig_is_public.py b/apps/gears/migrations/0005_rig_is_public.py new file mode 100644 index 0000000..afb87fd --- /dev/null +++ b/apps/gears/migrations/0005_rig_is_public.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0004_catalog_initial_data'), + ] + + operations = [ + migrations.AddField( + model_name='rig', + name='is_public', + field=models.BooleanField(default=False, verbose_name='public'), + ), + ] diff --git a/apps/gears/migrations/0007_scope_optics.py b/apps/gears/migrations/0007_scope_optics.py new file mode 100644 index 0000000..5cd13a8 --- /dev/null +++ b/apps/gears/migrations/0007_scope_optics.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', 'gears_0006_component_status'), + ] + + operations = [ + migrations.AddField( + model_name='scope', + name='adjustment_unit', + field=models.CharField( + blank=True, + choices=[('MOA', 'MOA (Minute of Angle)'), ('MRAD', 'MRAD (Milliradian)')], + max_length=4, + verbose_name='adjustment unit', + ), + ), + migrations.AddField( + model_name='scope', + name='focal_plane', + field=models.CharField( + blank=True, + choices=[('FFP', 'First Focal Plane (FFP)'), ('SFP', 'Second Focal Plane (SFP)')], + max_length=3, + verbose_name='focal plane', + ), + ), + ] diff --git a/apps/gears/migrations/0008_catalog_enrichment.py b/apps/gears/migrations/0008_catalog_enrichment.py new file mode 100644 index 0000000..7c20b2e --- /dev/null +++ b/apps/gears/migrations/0008_catalog_enrichment.py @@ -0,0 +1,428 @@ +""" +Data migration: enrich the public catalog with additional firearms (pistols, +semi-auto rifles, rimfire), scopes (with adjustment_unit/focal_plane), +suppressors, bipods and magazines. + +All items use get_or_create() to be idempotent. +""" +from django.db import migrations + + +def _get(apps, model_name): + return apps.get_model('gears', model_name) + + +def add_firearms(apps, schema_editor): + Firearm = _get(apps, 'Firearm') + V = 'VERIFIED' + + items = [ + # ── Pistols ───────────────────────────────────────────────────────── + dict(brand='Glock', model_name='G17 Gen5', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=114, magazine_capacity=17, status=V), + dict(brand='Glock', model_name='G19 Gen5', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=102, magazine_capacity=15, status=V), + dict(brand='Glock', model_name='G34 Gen5 MOS', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=135, magazine_capacity=17, status=V), + dict(brand='Glock', model_name='G19X', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=102, magazine_capacity=17, status=V), + dict(brand='Glock', model_name='G45', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=114, magazine_capacity=17, status=V), + dict(brand='SIG Sauer', model_name='P320 M17', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=112, magazine_capacity=17, status=V), + dict(brand='SIG Sauer', model_name='P320 X5 Legion', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=127, magazine_capacity=21, status=V), + dict(brand='SIG Sauer', model_name='P226', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=112, magazine_capacity=15, status=V), + dict(brand='SIG Sauer', model_name='P226 Legion', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=112, magazine_capacity=15, status=V), + dict(brand='CZ', model_name='Shadow 2', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=120, magazine_capacity=17, status=V), + dict(brand='CZ', model_name='SP-01 Tactical', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=119, magazine_capacity=18, status=V), + dict(brand='Beretta', model_name='92FS', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=125, magazine_capacity=15, status=V), + dict(brand='Beretta', model_name='APX A1', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=108, magazine_capacity=15, status=V), + dict(brand='Smith & Wesson', model_name='M&P 2.0 5" Pro', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=127, magazine_capacity=17, status=V), + dict(brand='Smith & Wesson', model_name='M&P 2.0 Compact 4"', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=102, magazine_capacity=15, status=V), + dict(brand='HK', model_name='VP9', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=108, magazine_capacity=15, status=V), + dict(brand='HK', model_name='USP Tactical 9mm', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=123, magazine_capacity=15, status=V), + dict(brand='Walther', model_name='Q5 Match Steel Frame', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=127, magazine_capacity=15, status=V), + dict(brand='Walther', model_name='PPQ M2 5"', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=127, magazine_capacity=15, status=V), + dict(brand='FN', model_name='FN 509 Tactical', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=121, magazine_capacity=17, status=V), + dict(brand='Canik', model_name='TP9SFx', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=127, magazine_capacity=20, status=V), + dict(brand='Springfield Armory', model_name='XD-M Elite 5.25" OSP', gear_type='FIREARM', + firearm_type='PISTOL', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=133, magazine_capacity=22, status=V), + # ── Semi-auto rifles ───────────────────────────────────────────────── + dict(brand='HK', model_name='HK416 A5 14.5"', gear_type='FIREARM', + firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO', + barrel_length_mm=368, magazine_capacity=30, status=V), + dict(brand='HK', model_name='HK417 A2 16"', gear_type='FIREARM', + firearm_type='RIFLE', caliber='7.62x51mm NATO', action='SEMI-AUTO', + barrel_length_mm=406, magazine_capacity=20, status=V), + dict(brand='SIG Sauer', model_name='MCX Spear 16"', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.8x51mm', action='SEMI-AUTO', + barrel_length_mm=406, magazine_capacity=20, status=V), + dict(brand='FN', model_name='SCAR 16S', gear_type='FIREARM', + firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO', + barrel_length_mm=406, magazine_capacity=30, status=V), + dict(brand='FN', model_name='SCAR 17S', gear_type='FIREARM', + firearm_type='RIFLE', caliber='7.62x51mm NATO', action='SEMI-AUTO', + barrel_length_mm=406, magazine_capacity=20, status=V), + dict(brand='Daniel Defense', model_name='DDM4 V7 16"', gear_type='FIREARM', + firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO', + barrel_length_mm=406, magazine_capacity=30, status=V), + dict(brand='Bravo Company Mfg', model_name='Recce-16 MCMR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='5.56x45mm NATO', action='SEMI-AUTO', + barrel_length_mm=406, magazine_capacity=30, status=V), + dict(brand='JP Enterprises', model_name='JP-5 16" PCC', gear_type='FIREARM', + firearm_type='RIFLE', caliber='9mm Luger', action='SEMI-AUTO', + barrel_length_mm=406, magazine_capacity=17, status=V), + dict(brand='SIG Sauer', model_name='MCX Rattler 5.5"', gear_type='FIREARM', + firearm_type='RIFLE', caliber='300 Blackout', action='SEMI-AUTO', + barrel_length_mm=140, magazine_capacity=30, status=V), + # ── Rimfire ────────────────────────────────────────────────────────── + dict(brand='Ruger', model_name='10/22 Carbine', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.22 LR', action='SEMI-AUTO', + barrel_length_mm=470, magazine_capacity=10, status=V), + dict(brand='Ruger', model_name='American Rimfire .22 LR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.22 LR', action='BOLT', + barrel_length_mm=559, magazine_capacity=10, status=V), + dict(brand='CZ', model_name='457 American .22 LR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.22 LR', action='BOLT', + barrel_length_mm=508, magazine_capacity=5, status=V), + dict(brand='CZ', model_name='457 Varmint .22 LR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.22 LR', action='BOLT', + barrel_length_mm=610, magazine_capacity=5, status=V), + dict(brand='Anschütz', model_name='1710 D HB .22 LR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.22 LR', action='BOLT', + barrel_length_mm=660, magazine_capacity=5, status=V), + dict(brand='Anschütz', model_name='2013 Supermatch .22 LR', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.22 LR', action='BOLT', + barrel_length_mm=690, magazine_capacity=1, status=V), + # ── Additional bolt-action rifles ──────────────────────────────────── + dict(brand='CZ', model_name='600 Alpha .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=560, status=V), + dict(brand='CZ', model_name='600 Range .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=V), + dict(brand='Christensen Arms', model_name='Modern Precision Rifle 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Christensen Arms', model_name='Modern Precision Rifle .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Proof Research', model_name='Glacier Ti 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=559, status=V), + dict(brand='Winchester', model_name='XPR .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Winchester', model_name='XPR 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Mossberg', model_name='MVP Precision .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=V), + dict(brand='Desert Tech', model_name='SRS A2 .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Desert Tech', model_name='SRS A2 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Victrix Armaments', model_name='Scorpio T 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=660, status=V), + dict(brand='Victrix Armaments', model_name='Venus .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=V), + dict(brand='Bergara', model_name='B-14 Squared Crest 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=559, status=V), + dict(brand='Bergara', model_name='Premier HMR Pro 6.5PRC', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 PRC', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Tikka', model_name='T3x UPR 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=610, status=V), + dict(brand='Sako', model_name='90 Adventure .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=572, status=V), + dict(brand='Sako', model_name='90 Peak 6.5CM', gear_type='FIREARM', + firearm_type='RIFLE', caliber='6.5 Creedmoor', action='BOLT', + barrel_length_mm=560, status=V), + dict(brand='Accuracy International', model_name='AX308 .308', gear_type='FIREARM', + firearm_type='RIFLE', caliber='.308 Win', action='BOLT', + barrel_length_mm=660, status=V), + ] + + for data in items: + Firearm.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + defaults=data, + ) + + +def add_scopes(apps, schema_editor): + Scope = _get(apps, 'Scope') + V = 'VERIFIED' + + items = [ + dict(brand='Athlon', model_name='Argos BTR 6-24x50', gear_type='SCOPE', + magnification_min=6, magnification_max=24, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Athlon', model_name='Cronus BTR 4.5-29x56', gear_type='SCOPE', + magnification_min=4, magnification_max=29, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Athlon', model_name='Ares BTR 4.5-27x50 FFP', gear_type='SCOPE', + magnification_min=4, magnification_max=27, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Primary Arms', model_name='PLx 6-30x56 FFP', gear_type='SCOPE', + magnification_min=6, magnification_max=30, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Primary Arms', model_name='SLx 4-16x44 FFP', gear_type='SCOPE', + magnification_min=4, magnification_max=16, objective_diameter_mm=44, + tube_diameter_mm=30, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Hawke', model_name='Sidewinder 30 6-24x50 FFP', gear_type='SCOPE', + magnification_min=6, magnification_max=24, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MILDOT', + adjustment_unit='MOA', focal_plane='FFP', status=V), + dict(brand='Delta Optical', model_name='Stryker HD 4.5-30x56 FFP', gear_type='SCOPE', + magnification_min=4, magnification_max=30, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Kahles', model_name='K525i 5-25x56 FFP', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='March', model_name='FX 8-80x56 FFP', gear_type='SCOPE', + magnification_min=8, magnification_max=80, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='March', model_name='Genesis 6-60x56 FFP', gear_type='SCOPE', + magnification_min=6, magnification_max=60, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='SIG Sauer', model_name='TANGO6T 1-6x24 FFP', gear_type='SCOPE', + magnification_min=1, magnification_max=6, objective_diameter_mm=24, + tube_diameter_mm=30, reticle_type='ILLUMINATED', + adjustment_unit='MOA', focal_plane='FFP', status=V), + dict(brand='SIG Sauer', model_name='TANGO4 4-16x44 FFP', gear_type='SCOPE', + magnification_min=4, magnification_max=16, objective_diameter_mm=44, + tube_diameter_mm=30, reticle_type='MILDOT', + adjustment_unit='MOA', focal_plane='FFP', status=V), + dict(brand='Leupold', model_name='VX-5HD 3-15x44 CDS-TZL3', gear_type='SCOPE', + magnification_min=3, magnification_max=15, objective_diameter_mm=44, + tube_diameter_mm=30, reticle_type='DUPLEX', + adjustment_unit='MOA', focal_plane='SFP', status=V), + dict(brand='Leupold', model_name='Mark 5HD 5-25x56 M5C3', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=35, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Burris', model_name='XTR III 3.3-18x50 FFP', gear_type='SCOPE', + magnification_min=3, magnification_max=18, objective_diameter_mm=50, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='US Optics', model_name='B-25 5-25x52 FFP', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=52, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='IOR Valdada', model_name='Terminator 3-18x50 FFP', gear_type='SCOPE', + magnification_min=3, magnification_max=18, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Vortex', model_name='Strike Eagle 5-25x56', gear_type='SCOPE', + magnification_min=5, magnification_max=25, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='ILLUMINATED', + adjustment_unit='MOA', focal_plane='FFP', status=V), + dict(brand='Vortex', model_name='Diamondback Tactical 6-24x50', gear_type='SCOPE', + magnification_min=6, magnification_max=24, objective_diameter_mm=50, + tube_diameter_mm=30, reticle_type='ETCHED', + adjustment_unit='MOA', focal_plane='FFP', status=V), + dict(brand='Nightforce', model_name='ATACR 7-35x56 F1', gear_type='SCOPE', + magnification_min=7, magnification_max=35, objective_diameter_mm=56, + tube_diameter_mm=34, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + dict(brand='Nightforce', model_name='NXS 5.5-22x56 F1', gear_type='SCOPE', + magnification_min=5, magnification_max=22, objective_diameter_mm=56, + tube_diameter_mm=30, reticle_type='MILDOT', + adjustment_unit='MRAD', focal_plane='FFP', status=V), + ] + + for data in items: + Scope.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + defaults=data, + ) + + +def add_suppressors(apps, schema_editor): + Suppressor = _get(apps, 'Suppressor') + V = 'VERIFIED' + + items = [ + dict(brand='OSS', model_name='HX-QD 556', gear_type='SUPPRESSOR', + max_caliber='5.56mm NATO', thread_pitch='1/2-28', + length_mm=170, weight_g=340, status=V), + dict(brand='Griffin Armament', model_name='Resistance 46M', gear_type='SUPPRESSOR', + max_caliber='9mm Luger', thread_pitch='1/2-28', + length_mm=190, weight_g=283, status=V), + dict(brand='SureFire', model_name='SOCOM556-RC2', gear_type='SUPPRESSOR', + max_caliber='5.56mm NATO', thread_pitch='1/2-28', + length_mm=152, weight_g=454, status=V), + dict(brand='AAC', model_name='SDN-6', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='5/8-24', + length_mm=185, weight_g=496, status=V), + dict(brand='SilencerCo', model_name='Omega 9K', gear_type='SUPPRESSOR', + max_caliber='9mm Luger', thread_pitch='1/2-28', + length_mm=127, weight_g=340, status=V), + dict(brand='Dead Air', model_name='Nomad-L', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='5/8-24', + length_mm=216, weight_g=510, status=V), + dict(brand='Rugged', model_name='Obsidian 9', gear_type='SUPPRESSOR', + max_caliber='9mm Luger', thread_pitch='1/2-28', + length_mm=203, weight_g=425, status=V), + dict(brand='Gemtech', model_name='G5-T 7.62', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='5/8-24', + length_mm=191, weight_g=510, status=V), + dict(brand='B&T', model_name='Rotex-V 7.62', gear_type='SUPPRESSOR', + max_caliber='7.62mm NATO', thread_pitch='5/8-24', + length_mm=220, weight_g=640, status=V), + ] + + for data in items: + Suppressor.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + defaults=data, + ) + + +def add_bipods(apps, schema_editor): + Bipod = _get(apps, 'Bipod') + V = 'VERIFIED' + + items = [ + dict(brand='Caldwell', model_name='XLA 9-13"', gear_type='BIPOD', + min_height_mm=229, max_height_mm=330, + attachment_type='Picatinny', status=V), + dict(brand='RRS', model_name='SOAR B2 Arca', gear_type='BIPOD', + min_height_mm=177, max_height_mm=330, + attachment_type='Arca-Swiss/Picatinny', status=V), + dict(brand='Trigger-Tech', model_name='Diamond Bipod', gear_type='BIPOD', + min_height_mm=152, max_height_mm=280, + attachment_type='Picatinny', status=V), + dict(brand='Spartan Precision', model_name='Javelin Lite', gear_type='BIPOD', + min_height_mm=178, max_height_mm=330, + attachment_type='Sling stud/Picatinny', status=V), + dict(brand='Fortmeier', model_name='H-POD', gear_type='BIPOD', + min_height_mm=200, max_height_mm=380, + attachment_type='Picatinny', status=V), + ] + + for data in items: + Bipod.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + defaults=data, + ) + + +def add_magazines(apps, schema_editor): + Magazine = _get(apps, 'Magazine') + V = 'VERIFIED' + + items = [ + dict(brand='Glock', model_name='G17 Factory 17rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=17, status=V), + dict(brand='Glock', model_name='G17 Factory 33rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=33, status=V), + dict(brand='Glock', model_name='G19 Factory 15rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=15, status=V), + dict(brand='SIG Sauer', model_name='P320 Factory 17rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=17, status=V), + dict(brand='SIG Sauer', model_name='P320 Factory 21rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=21, status=V), + dict(brand='CZ', model_name='Shadow 2 Factory 17rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=17, status=V), + dict(brand='HK', model_name='VP9 Factory 15rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=15, status=V), + dict(brand='Beretta', model_name='92FS Factory 15rd', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=15, status=V), + dict(brand='Magpul', model_name='PMAG 10 AR/M4 Gen M3', gear_type='MAGAZINE', + caliber='5.56x45mm NATO', capacity=10, status=V), + dict(brand='Magpul', model_name='PMAG D-60 AR/M4', gear_type='MAGAZINE', + caliber='5.56x45mm NATO', capacity=60, status=V), + dict(brand='Ruger', model_name='BX-25 10/22', gear_type='MAGAZINE', + caliber='.22 LR', capacity=25, status=V), + dict(brand='Ruger', model_name='BX-1 10/22', gear_type='MAGAZINE', + caliber='.22 LR', capacity=10, status=V), + dict(brand='CZ', model_name='457 Factory 5rd', gear_type='MAGAZINE', + caliber='.22 LR', capacity=5, status=V), + dict(brand='CZ', model_name='457 Factory 10rd', gear_type='MAGAZINE', + caliber='.22 LR', capacity=10, status=V), + dict(brand='Magpul', model_name='PMAG 20 GL9 (Glock 17)', gear_type='MAGAZINE', + caliber='9mm Luger', capacity=20, status=V), + ] + + for data in items: + Magazine.objects.get_or_create( + brand=data['brand'], + model_name=data['model_name'], + caliber=data.get('caliber', ''), + defaults=data, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0007_scope_optics'), + ] + + operations = [ + migrations.RunPython(add_firearms, migrations.RunPython.noop), + migrations.RunPython(add_scopes, migrations.RunPython.noop), + migrations.RunPython(add_suppressors, migrations.RunPython.noop), + migrations.RunPython(add_bipods, migrations.RunPython.noop), + migrations.RunPython(add_magazines, migrations.RunPython.noop), + ] diff --git a/apps/gears/migrations/0009_alter_brass_submitted_by_alter_bullet_submitted_by_and_more.py b/apps/gears/migrations/0009_alter_brass_submitted_by_alter_bullet_submitted_by_and_more.py new file mode 100644 index 0000000..7b991cd --- /dev/null +++ b/apps/gears/migrations/0009_alter_brass_submitted_by_alter_bullet_submitted_by_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.16 on 2026-03-25 08:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gears', '0008_catalog_enrichment'), + ] + + operations = [ + migrations.AlterField( + model_name='brass', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'), + ), + migrations.AlterField( + model_name='bullet', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'), + ), + migrations.AlterField( + model_name='powder', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'), + ), + migrations.AlterField( + model_name='primer', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_%(class)ss', to=settings.AUTH_USER_MODEL, verbose_name='submitted by'), + ), + ] diff --git a/apps/gears/migrations/0010_rig_photo.py b/apps/gears/migrations/0010_rig_photo.py new file mode 100644 index 0000000..90e046f --- /dev/null +++ b/apps/gears/migrations/0010_rig_photo.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2026-03-25 10:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('photos', '0001_initial'), + ('gears', '0009_alter_brass_submitted_by_alter_bullet_submitted_by_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='rig', + name='photo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rig', to='photos.photo', verbose_name='photo'), + ), + ] diff --git a/apps/gears/migrations/0011_rig_ballistic_fields.py b/apps/gears/migrations/0011_rig_ballistic_fields.py new file mode 100644 index 0000000..ae99872 --- /dev/null +++ b/apps/gears/migrations/0011_rig_ballistic_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2026-03-30 09:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0010_rig_photo'), + ] + + operations = [ + migrations.AddField( + model_name='rig', + name='scope_height_mm', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='scope height above bore (mm)'), + ), + migrations.AddField( + model_name='rig', + name='zero_distance_m', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='zero distance (m)'), + ), + ] diff --git a/apps/gears/migrations/0012_caliber_fk.py b/apps/gears/migrations/0012_caliber_fk.py new file mode 100644 index 0000000..c4a21c3 --- /dev/null +++ b/apps/gears/migrations/0012_caliber_fk.py @@ -0,0 +1,142 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0011_rig_ballistic_fields'), + ('calibers', '0001_initial'), + ] + + operations = [ + # ── Firearm.caliber ────────────────────────────────────────────────── + migrations.RemoveField( + model_name='firearm', + name='caliber', + ), + migrations.AddField( + model_name='firearm', + name='caliber', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='calibers.caliber', + verbose_name='caliber', + ), + ), + + # ── Suppressor.max_caliber ─────────────────────────────────────────── + migrations.RemoveField( + model_name='suppressor', + name='max_caliber', + ), + migrations.AddField( + model_name='suppressor', + name='max_caliber', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='calibers.caliber', + verbose_name='max caliber', + ), + ), + + # ── Magazine.caliber ───────────────────────────────────────────────── + migrations.RemoveField( + model_name='magazine', + name='caliber', + ), + migrations.AddField( + model_name='magazine', + name='caliber', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='calibers.caliber', + verbose_name='caliber', + ), + ), + + # ── Ammo.caliber — remove constraint first ─────────────────────────── + migrations.RemoveConstraint( + model_name='ammo', + name='unique_ammo_brand_name_caliber', + ), + migrations.RemoveField( + model_name='ammo', + name='caliber', + ), + migrations.AddField( + model_name='ammo', + name='caliber', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='calibers.caliber', + verbose_name='caliber', + ), + ), + migrations.AddConstraint( + model_name='ammo', + constraint=models.UniqueConstraint( + fields=['brand', 'name', 'caliber'], + name='unique_ammo_brand_name_caliber', + ), + ), + + # ── Brass.caliber — remove constraint first ─────────────────────────── + migrations.RemoveConstraint( + model_name='brass', + name='unique_brass_brand_caliber', + ), + migrations.RemoveField( + model_name='brass', + name='caliber', + ), + migrations.AddField( + model_name='brass', + name='caliber', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='calibers.caliber', + verbose_name='caliber', + ), + ), + migrations.AddConstraint( + model_name='brass', + constraint=models.UniqueConstraint( + fields=['brand', 'caliber'], + name='unique_brass_brand_caliber', + ), + ), + + # ── ReloadRecipe.caliber ───────────────────────────────────────────── + migrations.RemoveField( + model_name='reloadrecipe', + name='caliber', + ), + migrations.AddField( + model_name='reloadrecipe', + name='caliber', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='calibers.caliber', + verbose_name='caliber', + ), + ), + ] diff --git a/apps/gears/migrations/0013_remove_firearm_action.py b/apps/gears/migrations/0013_remove_firearm_action.py new file mode 100644 index 0000000..7aaea0a --- /dev/null +++ b/apps/gears/migrations/0013_remove_firearm_action.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.16 on 2026-03-31 12:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0012_caliber_fk'), + ] + + operations = [ + migrations.AlterModelOptions( + name='ammo', + options={'ordering': ['brand', 'name', 'caliber__name'], 'verbose_name': 'ammo', 'verbose_name_plural': 'ammo'}, + ), + migrations.AlterModelOptions( + name='brass', + options={'ordering': ['brand', 'caliber__name'], 'verbose_name': 'brass', 'verbose_name_plural': 'brass'}, + ), + migrations.RemoveField( + model_name='firearm', + name='action', + ), + ] diff --git a/apps/gears/migrations/0014_reloadrecipe_is_public.py b/apps/gears/migrations/0014_reloadrecipe_is_public.py new file mode 100644 index 0000000..89cf9b3 --- /dev/null +++ b/apps/gears/migrations/0014_reloadrecipe_is_public.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0013_remove_firearm_action'), + ] + + operations = [ + migrations.AddField( + model_name='reloadrecipe', + name='is_public', + field=models.BooleanField(default=False, verbose_name='public'), + ), + ] diff --git a/apps/gears/migrations/__init__.py b/apps/gears/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gears/migrations/gears_0006_component_status.py b/apps/gears/migrations/gears_0006_component_status.py new file mode 100644 index 0000000..7134fb4 --- /dev/null +++ b/apps/gears/migrations/gears_0006_component_status.py @@ -0,0 +1,86 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0005_rig_is_public'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # Primer + migrations.AddField( + model_name='primer', + name='status', + field=models.CharField( + choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], + default='VERIFIED', max_length=10, verbose_name='status', + ), + ), + migrations.AddField( + model_name='primer', + name='submitted_by', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='submitted_primers', to=settings.AUTH_USER_MODEL, + verbose_name='submitted by', + ), + ), + # Brass + migrations.AddField( + model_name='brass', + name='status', + field=models.CharField( + choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], + default='VERIFIED', max_length=10, verbose_name='status', + ), + ), + migrations.AddField( + model_name='brass', + name='submitted_by', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='submitted_brasss', to=settings.AUTH_USER_MODEL, + verbose_name='submitted by', + ), + ), + # Bullet + migrations.AddField( + model_name='bullet', + name='status', + field=models.CharField( + choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], + default='VERIFIED', max_length=10, verbose_name='status', + ), + ), + migrations.AddField( + model_name='bullet', + name='submitted_by', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='submitted_bullets', to=settings.AUTH_USER_MODEL, + verbose_name='submitted by', + ), + ), + # Powder + migrations.AddField( + model_name='powder', + name='status', + field=models.CharField( + choices=[('PENDING', 'Pending Verification'), ('VERIFIED', 'Verified'), ('REJECTED', 'Rejected')], + default='VERIFIED', max_length=10, verbose_name='status', + ), + ), + migrations.AddField( + model_name='powder', + name='submitted_by', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='submitted_powders', to=settings.AUTH_USER_MODEL, + verbose_name='submitted by', + ), + ), + ] diff --git a/apps/gears/models.py b/apps/gears/models.py new file mode 100644 index 0000000..6445836 --- /dev/null +++ b/apps/gears/models.py @@ -0,0 +1,727 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +# ── Choices ─────────────────────────────────────────────────────────────────── + +class GearStatus(models.TextChoices): + PENDING = 'PENDING', _('Pending Verification') + VERIFIED = 'VERIFIED', _('Verified') + REJECTED = 'REJECTED', _('Rejected') + + +class GearType(models.TextChoices): + FIREARM = 'FIREARM', _('Firearm') + SCOPE = 'SCOPE', _('Scope') + SUPPRESSOR = 'SUPPRESSOR', _('Suppressor') + BIPOD = 'BIPOD', _('Bipod') + MAGAZINE = 'MAGAZINE', _('Magazine') + + +class FirearmType(models.TextChoices): + RIFLE = 'RIFLE', _('Rifle') + PISTOL = 'PISTOL', _('Pistol') + SHOTGUN = 'SHOTGUN', _('Shotgun') + REVOLVER = 'REVOLVER', _('Revolver') + CARBINE = 'CARBINE', _('Carbine') + + + +class ReticleType(models.TextChoices): + DUPLEX = 'DUPLEX', _('Duplex') + MILDOT = 'MILDOT', _('Mil-Dot') + BDC = 'BDC', _('BDC') + ILLUMINATED = 'ILLUMINATED', _('Illuminated') + ETCHED = 'ETCHED', _('Etched Glass') + + +class AdjustmentUnit(models.TextChoices): + MOA = 'MOA', _('MOA (Minute of Angle)') + MRAD = 'MRAD', _('MRAD (Milliradian)') + + +class FocalPlane(models.TextChoices): + FFP = 'FFP', _('First Focal Plane (FFP)') + SFP = 'SFP', _('Second Focal Plane (SFP)') + + +class AttachmentType(models.TextChoices): + PICATINNY = 'PICATINNY', _('Picatinny Rail') + SLING_STUD = 'SLING_STUD', _('Sling Stud') + ARCA_SWISS = 'ARCA_SWISS', _('Arca-Swiss') + M_LOK = 'M_LOK', _('M-LOK') + KEYMOD = 'KEYMOD', _('KeyMod') + + +class RigRole(models.TextChoices): + PRIMARY = 'PRIMARY', _('Primary Firearm') + OPTIC = 'OPTIC', _('Optic / Scope') + SUPPRESSOR = 'SUPPRESSOR', _('Suppressor') + BIPOD = 'BIPOD', _('Bipod') + MAGAZINE = 'MAGAZINE', _('Magazine') + OTHER = 'OTHER', _('Other Accessory') + + +class BulletType(models.TextChoices): + FMJ = 'FMJ', _('Full Metal Jacket') + HP = 'HP', _('Hollow Point') + BTHP = 'BTHP', _('Boat Tail Hollow Point') + SP = 'SP', _('Soft Point') + HPBT = 'HPBT', _('Hollow Point Boat Tail') + SMK = 'SMK', _('Sierra MatchKing') + A_TIP = 'A_TIP', _('Hornady A-Tip') + MONO = 'MONO', _('Monolithic / Solid') + + +class PrimerSize(models.TextChoices): + SMALL_PISTOL = 'SP', _('Small Pistol') + LARGE_PISTOL = 'LP', _('Large Pistol') + SMALL_RIFLE = 'SR', _('Small Rifle') + LARGE_RIFLE = 'LR', _('Large Rifle') + LARGE_RIFLE_MAG = 'LRM', _('Large Rifle Magnum') + + +class CaseMaterial(models.TextChoices): + BRASS = 'BRASS', _('Brass') + STEEL = 'STEEL', _('Steel') + ALUMINUM = 'ALUMINUM', _('Aluminum') + NICKEL_PLATED = 'NICKEL', _('Nickel-Plated Brass') + + +class PowderType(models.TextChoices): + BALL = 'BALL', _('Ball / Spherical') + EXTRUDED = 'EXTRUDED', _('Extruded / Stick') + FLAKE = 'FLAKE', _('Flake') + + +class CrimpType(models.TextChoices): + NONE = 'NONE', _('No Crimp') + TAPER = 'TAPER', _('Taper Crimp') + ROLL = 'ROLL', _('Roll Crimp') + + +# ── Gear catalog (MTI) ──────────────────────────────────────────────────────── + +class Gear(models.Model): + """ + Base catalog entry shared by all gear types. + Concrete entries live in child tables via multi-table inheritance. + gear_type acts as a discriminator and is set automatically by each subclass. + """ + brand = models.CharField(_('brand'), max_length=100) + model_name = models.CharField(_('model name'), max_length=150) + description = models.TextField(_('description'), blank=True) + gear_type = models.CharField( + _('gear type'), max_length=20, choices=GearType.choices, editable=False + ) + status = models.CharField( + _('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.PENDING + ) + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='submitted_gears', + verbose_name=_('submitted by'), + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='reviewed_gears', + verbose_name=_('reviewed by'), + ) + reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True) + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + updated_at = models.DateTimeField(_('updated at'), auto_now=True) + + class Meta: + verbose_name = _('gear') + verbose_name_plural = _('gears') + ordering = ['brand', 'model_name'] + constraints = [ + models.UniqueConstraint( + fields=['brand', 'model_name'], name='unique_gear_brand_model' + ) + ] + + def __str__(self): + return f"{self.brand} {self.model_name}" + + def verify(self, reviewed_by): + self.status = GearStatus.VERIFIED + self.reviewed_by = reviewed_by + self.reviewed_at = timezone.now() + self.save(update_fields=['status', 'reviewed_by', 'reviewed_at']) + + def reject(self, reviewed_by): + self.status = GearStatus.REJECTED + self.reviewed_by = reviewed_by + self.reviewed_at = timezone.now() + self.save(update_fields=['status', 'reviewed_by', 'reviewed_at']) + + +class Firearm(Gear): + firearm_type = models.CharField(_('firearm type'), max_length=10, choices=FirearmType.choices) + caliber = models.ForeignKey( + 'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL, + related_name='+', verbose_name=_('caliber'), + ) + barrel_length_mm = models.DecimalField( + _('barrel length (mm)'), max_digits=6, decimal_places=1, null=True, blank=True + ) + magazine_capacity = models.PositiveSmallIntegerField( + _('magazine capacity'), null=True, blank=True + ) + + def save(self, *args, **kwargs): + self.gear_type = GearType.FIREARM + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('firearm') + verbose_name_plural = _('firearms') + + +class Scope(Gear): + magnification_min = models.DecimalField(_('min magnification'), max_digits=5, decimal_places=1) + magnification_max = models.DecimalField(_('max magnification'), max_digits=5, decimal_places=1) + objective_diameter_mm = models.DecimalField( + _('objective diameter (mm)'), max_digits=5, decimal_places=1 + ) + tube_diameter_mm = models.DecimalField( + _('tube diameter (mm)'), max_digits=5, decimal_places=1, default=30 + ) + reticle_type = models.CharField( + _('reticle type'), max_length=20, choices=ReticleType.choices, blank=True + ) + adjustment_unit = models.CharField( + _('adjustment unit'), max_length=4, choices=AdjustmentUnit.choices, blank=True + ) + focal_plane = models.CharField( + _('focal plane'), max_length=3, choices=FocalPlane.choices, blank=True + ) + + def save(self, *args, **kwargs): + self.gear_type = GearType.SCOPE + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('scope') + verbose_name_plural = _('scopes') + + +class Suppressor(Gear): + max_caliber = models.ForeignKey( + 'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL, + related_name='+', verbose_name=_('max caliber'), + ) + thread_pitch = models.CharField(_('thread pitch'), max_length=20, blank=True) + length_mm = models.DecimalField( + _('length (mm)'), max_digits=6, decimal_places=1, null=True, blank=True + ) + weight_g = models.DecimalField( + _('weight (g)'), max_digits=6, decimal_places=1, null=True, blank=True + ) + + def save(self, *args, **kwargs): + self.gear_type = GearType.SUPPRESSOR + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('suppressor') + verbose_name_plural = _('suppressors') + + +class Bipod(Gear): + min_height_mm = models.DecimalField( + _('min height (mm)'), max_digits=6, decimal_places=1, null=True, blank=True + ) + max_height_mm = models.DecimalField( + _('max height (mm)'), max_digits=6, decimal_places=1, null=True, blank=True + ) + attachment_type = models.CharField( + _('attachment type'), max_length=20, choices=AttachmentType.choices, blank=True + ) + + def save(self, *args, **kwargs): + self.gear_type = GearType.BIPOD + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('bipod') + verbose_name_plural = _('bipods') + + +class Magazine(Gear): + caliber = models.ForeignKey( + 'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL, + related_name='+', verbose_name=_('caliber'), + ) + capacity = models.PositiveSmallIntegerField(_('capacity')) + + def save(self, *args, **kwargs): + self.gear_type = GearType.MAGAZINE + super().save(*args, **kwargs) + + class Meta: + verbose_name = _('magazine') + verbose_name_plural = _('magazines') + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_GEAR_TYPE_ATTR = { + GearType.FIREARM: 'firearm', + GearType.SCOPE: 'scope', + GearType.SUPPRESSOR: 'suppressor', + GearType.BIPOD: 'bipod', + GearType.MAGAZINE: 'magazine', +} + + +def get_concrete_gear(gear): + """Return the concrete MTI subclass instance for a base Gear instance.""" + attr = _GEAR_TYPE_ATTR.get(gear.gear_type) + if attr: + try: + return getattr(gear, attr) + except Exception: + pass + return gear + + +# ── User inventory ──────────────────────────────────────────────────────────── + +class UserGear(models.Model): + """A user's personal instance of a catalog Gear entry.""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='inventory', verbose_name=_('user'), + ) + gear = models.ForeignKey( + Gear, on_delete=models.CASCADE, + related_name='user_instances', verbose_name=_('gear'), + ) + nickname = models.CharField(_('nickname'), max_length=100, blank=True) + serial_number = models.CharField(_('serial number'), max_length=100, blank=True) + purchase_date = models.DateField(_('purchase date'), null=True, blank=True) + notes = models.TextField(_('notes'), blank=True) + added_at = models.DateTimeField(_('added at'), auto_now_add=True) + + class Meta: + verbose_name = _('owned gear') + verbose_name_plural = _('owned gears') + ordering = ['-added_at'] + + def __str__(self): + label = self.nickname or str(self.gear) + return f"{self.user.email} — {label}" + + +# ── Rigs ────────────────────────────────────────────────────────────────────── + +class Rig(models.Model): + """A named loadout: a collection of UserGear items with optional roles.""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='rigs', verbose_name=_('user'), + ) + name = models.CharField(_('name'), max_length=100) + description = models.TextField(_('description'), blank=True) + is_public = models.BooleanField(_('public'), default=False) + photo = models.ForeignKey( + 'photos.Photo', + null=True, blank=True, + on_delete=models.SET_NULL, + related_name='rig', + verbose_name=_('photo'), + ) + items = models.ManyToManyField( + UserGear, through='RigItem', related_name='rigs', + verbose_name=_('items'), + ) + # Ballistic computation inputs + zero_distance_m = models.PositiveSmallIntegerField( + _('zero distance (m)'), null=True, blank=True, + ) + scope_height_mm = models.DecimalField( + _('scope height above bore (mm)'), max_digits=5, decimal_places=1, + null=True, blank=True, + ) + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + updated_at = models.DateTimeField(_('updated at'), auto_now=True) + + class Meta: + verbose_name = _('rig') + verbose_name_plural = _('rigs') + ordering = ['-created_at'] + constraints = [ + models.UniqueConstraint(fields=['user', 'name'], name='unique_rig_per_user') + ] + + def __str__(self): + return f"{self.user.email} — {self.name}" + + +class RigItem(models.Model): + """Through table linking a UserGear to a Rig with an optional role label.""" + rig = models.ForeignKey( + Rig, on_delete=models.CASCADE, + related_name='rig_items', verbose_name=_('rig'), + ) + user_gear = models.ForeignKey( + UserGear, on_delete=models.CASCADE, + related_name='rig_items', verbose_name=_('gear'), + ) + role = models.CharField( + _('role'), max_length=20, choices=RigRole.choices, default=RigRole.OTHER + ) + + class Meta: + verbose_name = _('rig item') + verbose_name_plural = _('rig items') + constraints = [ + models.UniqueConstraint( + fields=['rig', 'user_gear'], name='unique_gear_per_rig' + ) + ] + + def __str__(self): + return f"{self.rig.name} / {self.user_gear} [{self.role}]" + + def clean(self): + if self.user_gear.user_id != self.rig.user_id: + raise ValidationError( + {'user_gear': _('This gear does not belong to the rig owner.')} + ) + + if self.role == RigRole.PRIMARY: + if self.user_gear.gear.gear_type != GearType.FIREARM: + raise ValidationError( + {'role': _('The PRIMARY slot must contain a Firearm.')} + ) + qs = RigItem.objects.filter(rig=self.rig, role=RigRole.PRIMARY) + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError( + {'role': _('A rig can only have one primary firearm.')} + ) + + +# ── Ammo catalog ────────────────────────────────────────────────────────────── + +class Ammo(models.Model): + """ + Commercial/factory ammunition catalog entry. + Independent of the Gear MTI hierarchy. + Same PENDING/VERIFIED/REJECTED moderation workflow as Gear. + """ + brand = models.CharField(_('brand'), max_length=100) + name = models.CharField(_('name'), max_length=150) + caliber = models.ForeignKey( + 'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL, + related_name='+', verbose_name=_('caliber'), + ) + bullet_weight_gr = models.DecimalField( + _('bullet weight (gr)'), max_digits=6, decimal_places=1 + ) + bullet_type = models.CharField( + _('bullet type'), max_length=5, choices=BulletType.choices + ) + primer_size = models.CharField( + _('primer size'), max_length=3, choices=PrimerSize.choices, blank=True + ) + case_material = models.CharField( + _('case material'), max_length=10, choices=CaseMaterial.choices, + default=CaseMaterial.BRASS + ) + muzzle_velocity_fps = models.DecimalField( + _('muzzle velocity (fps)'), max_digits=6, decimal_places=1, null=True, blank=True + ) + muzzle_energy_ftlb = models.DecimalField( + _('muzzle energy (ft·lb)'), max_digits=7, decimal_places=1, null=True, blank=True + ) + box_count = models.PositiveSmallIntegerField(_('box count'), null=True, blank=True) + notes = models.TextField(_('notes'), blank=True) + status = models.CharField( + _('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.PENDING + ) + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='submitted_ammo', + verbose_name=_('submitted by'), + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='reviewed_ammo', + verbose_name=_('reviewed by'), + ) + reviewed_at = models.DateTimeField(_('reviewed at'), null=True, blank=True) + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + updated_at = models.DateTimeField(_('updated at'), auto_now=True) + + class Meta: + verbose_name = _('ammo') + verbose_name_plural = _('ammo') + ordering = ['brand', 'name', 'caliber__name'] + constraints = [ + models.UniqueConstraint( + fields=['brand', 'name', 'caliber'], + name='unique_ammo_brand_name_caliber' + ) + ] + + def __str__(self): + caliber_str = self.caliber.name if self.caliber_id else '?' + return f"{self.brand} {self.name} ({caliber_str})" + + def verify(self, reviewed_by): + self.status = GearStatus.VERIFIED + self.reviewed_by = reviewed_by + self.reviewed_at = timezone.now() + self.save(update_fields=['status', 'reviewed_by', 'reviewed_at']) + + def reject(self, reviewed_by): + self.status = GearStatus.REJECTED + self.reviewed_by = reviewed_by + self.reviewed_at = timezone.now() + self.save(update_fields=['status', 'reviewed_by', 'reviewed_at']) + + +# ── Reloading components ─────────────────────────────────────────────────────── + +class ComponentMixin(models.Model): + """ + Shared moderation fields for user-submitted reload components. + Existing catalog entries default to VERIFIED for backward compatibility. + """ + status = models.CharField( + _('status'), max_length=10, choices=GearStatus.choices, default=GearStatus.VERIFIED + ) + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, + related_name='submitted_%(class)ss', + verbose_name=_('submitted by'), + ) + + class Meta: + abstract = True + + def verify(self, reviewed_by=None): + self.status = GearStatus.VERIFIED + self.save(update_fields=['status']) + + def reject(self, reviewed_by=None): + self.status = GearStatus.REJECTED + self.save(update_fields=['status']) + + +class Primer(ComponentMixin): + """Primer reference — can be user-submitted (PENDING) or admin-verified.""" + brand = models.CharField(_('brand'), max_length=100) + name = models.CharField(_('name'), max_length=100) + size = models.CharField(_('size'), max_length=3, choices=PrimerSize.choices) + notes = models.TextField(_('notes'), blank=True) + + class Meta: + verbose_name = _('primer') + verbose_name_plural = _('primers') + ordering = ['brand', 'name'] + constraints = [ + models.UniqueConstraint(fields=['brand', 'name'], name='unique_primer_brand_name') + ] + + def __str__(self): + return f"{self.brand} {self.name} ({self.get_size_display()})" + + +class Brass(ComponentMixin): + """Brass/case reference — can be user-submitted (PENDING) or admin-verified.""" + brand = models.CharField(_('brand'), max_length=100) + caliber = models.ForeignKey( + 'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL, + related_name='+', verbose_name=_('caliber'), + ) + primer_pocket = models.CharField( + _('primer pocket'), max_length=3, choices=PrimerSize.choices, blank=True + ) + trim_length_mm = models.DecimalField( + _('trim-to length (mm)'), max_digits=6, decimal_places=2, null=True, blank=True + ) + notes = models.TextField(_('notes'), blank=True) + + class Meta: + verbose_name = _('brass') + verbose_name_plural = _('brass') + ordering = ['brand', 'caliber__name'] + constraints = [ + models.UniqueConstraint( + fields=['brand', 'caliber'], name='unique_brass_brand_caliber' + ) + ] + + def __str__(self): + caliber_str = self.caliber.name if self.caliber_id else '?' + return f"{self.brand} {caliber_str}" + + +class Bullet(ComponentMixin): + """Bullet/projectile reference — can be user-submitted (PENDING) or admin-verified.""" + brand = models.CharField(_('brand'), max_length=100) + model_name = models.CharField(_('model name'), max_length=150) + weight_gr = models.DecimalField(_('weight (gr)'), max_digits=6, decimal_places=1) + bullet_type = models.CharField(_('bullet type'), max_length=5, choices=BulletType.choices) + diameter_mm = models.DecimalField( + _('diameter (mm)'), max_digits=5, decimal_places=3, null=True, blank=True + ) + length_mm = models.DecimalField( + _('length (mm)'), max_digits=5, decimal_places=2, null=True, blank=True + ) + bc_g1 = models.DecimalField( + _('BC (G1)'), max_digits=5, decimal_places=4, null=True, blank=True + ) + bc_g7 = models.DecimalField( + _('BC (G7)'), max_digits=5, decimal_places=4, null=True, blank=True + ) + + class Meta: + verbose_name = _('bullet') + verbose_name_plural = _('bullets') + ordering = ['brand', 'model_name', 'weight_gr'] + constraints = [ + models.UniqueConstraint( + fields=['brand', 'model_name', 'weight_gr'], + name='unique_bullet_brand_model_weight' + ) + ] + + def __str__(self): + return f"{self.brand} {self.model_name} {self.weight_gr}gr" + + +class Powder(ComponentMixin): + """Propellant powder reference — can be user-submitted (PENDING) or admin-verified.""" + brand = models.CharField(_('brand'), max_length=100) + name = models.CharField(_('name'), max_length=100) + powder_type = models.CharField( + _('powder type'), max_length=10, choices=PowderType.choices, blank=True + ) + burn_rate_index = models.PositiveSmallIntegerField( + _('burn rate index'), null=True, blank=True, + help_text=_('Lower = faster burning. Used for relative ordering only.'), + ) + notes = models.TextField(_('notes'), blank=True) + + class Meta: + verbose_name = _('powder') + verbose_name_plural = _('powders') + ordering = ['burn_rate_index', 'brand', 'name'] + constraints = [ + models.UniqueConstraint( + fields=['brand', 'name'], name='unique_powder_brand_name' + ) + ] + + def __str__(self): + return f"{self.brand} {self.name}" + + +# ── Reload development ──────────────────────────────────────────────────────── + +class ReloadRecipe(models.Model): + """ + A reloading recipe: a fixed combination of primer, brass, and bullet + owned by one user. Batches (different powder charges) hang off this. + """ + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='reload_recipes', verbose_name=_('user'), + ) + name = models.CharField(_('name'), max_length=150) + caliber = models.ForeignKey( + 'calibers.Caliber', null=True, blank=True, on_delete=models.SET_NULL, + related_name='+', verbose_name=_('caliber'), + ) + primer = models.ForeignKey( + Primer, on_delete=models.PROTECT, + related_name='recipes', verbose_name=_('primer'), + ) + brass = models.ForeignKey( + Brass, on_delete=models.PROTECT, + related_name='recipes', verbose_name=_('brass'), + ) + bullet = models.ForeignKey( + Bullet, on_delete=models.PROTECT, + related_name='recipes', verbose_name=_('bullet'), + ) + 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 = _('reload recipe') + verbose_name_plural = _('reload recipes') + ordering = ['-created_at'] + constraints = [ + models.UniqueConstraint( + fields=['user', 'name'], name='unique_recipe_name_per_user' + ) + ] + + def __str__(self): + caliber_str = self.caliber.name if self.caliber_id else '?' + return f"{self.user.email} — {self.name} ({caliber_str})" + + +class ReloadedAmmoBatch(models.Model): + """ + A specific powder charge variant within a ReloadRecipe. + Multiple batches under one recipe represent the powder charge development workflow. + ShotGroups in apps.tools can link to a batch to track performance per charge. + """ + recipe = models.ForeignKey( + ReloadRecipe, on_delete=models.CASCADE, + related_name='batches', verbose_name=_('recipe'), + ) + powder = models.ForeignKey( + Powder, on_delete=models.PROTECT, + related_name='batches', verbose_name=_('powder'), + ) + powder_charge_gr = models.DecimalField( + _('powder charge (gr)'), max_digits=5, decimal_places=2 + ) + quantity = models.PositiveSmallIntegerField( + _('quantity loaded'), null=True, blank=True + ) + oal_mm = models.DecimalField( + _('overall length (mm)'), max_digits=6, decimal_places=2, null=True, blank=True + ) + coal_mm = models.DecimalField( + _('cartridge overall length to ogive (mm)'), max_digits=6, decimal_places=2, + null=True, blank=True + ) + crimp = models.CharField( + _('crimp'), max_length=6, choices=CrimpType.choices, default=CrimpType.NONE + ) + case_prep_notes = models.TextField(_('case prep notes'), blank=True) + notes = models.TextField(_('notes'), blank=True) + loaded_at = models.DateField(_('loaded at'), null=True, blank=True) + created_at = models.DateTimeField(_('created at'), auto_now_add=True) + updated_at = models.DateTimeField(_('updated at'), auto_now=True) + + class Meta: + verbose_name = _('reloaded ammo batch') + verbose_name_plural = _('reloaded ammo batches') + ordering = ['recipe', 'powder_charge_gr'] + constraints = [ + models.UniqueConstraint( + fields=['recipe', 'powder', 'powder_charge_gr'], + name='unique_batch_charge_per_recipe_powder' + ) + ] + + def __str__(self): + return f"{self.recipe.name} / {self.powder} {self.powder_charge_gr}gr" diff --git a/apps/gears/permissions.py b/apps/gears/permissions.py new file mode 100644 index 0000000..8782bcc --- /dev/null +++ b/apps/gears/permissions.py @@ -0,0 +1,16 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsAdminOrReadOnly(BasePermission): + """ + Read access for any authenticated user. + Write access (create / update / delete) restricted to staff only. + Regular users may still POST (to submit a pending gear request) — + that special case is handled at the view level, not here. + """ + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if request.method in SAFE_METHODS: + return True + return request.user.is_staff diff --git a/apps/gears/serializers.py b/apps/gears/serializers.py new file mode 100644 index 0000000..1d3651d --- /dev/null +++ b/apps/gears/serializers.py @@ -0,0 +1,391 @@ +from rest_framework import serializers + +from apps.calibers.models import Caliber + +from .models import ( + Ammo, + Bipod, + Brass, + Bullet, + Firearm, + Gear, + GearStatus, + Magazine, + Powder, + Primer, + ReloadedAmmoBatch, + ReloadRecipe, + Rig, + RigItem, + Scope, + Suppressor, + UserGear, + get_concrete_gear, +) + + +# ── Caliber helper serializer ───────────────────────────────────────────────── + +class CaliberMinSerializer(serializers.ModelSerializer): + class Meta: + model = Caliber + fields = ['id', 'name', 'short_name'] + + +# ── Gear catalog serializers ────────────────────────────────────────────────── + +class GearBaseSerializer(serializers.ModelSerializer): + """Common read-only fields for every gear type (used in list views).""" + + class Meta: + model = Gear + fields = [ + 'id', 'brand', 'model_name', 'description', + 'gear_type', 'status', 'created_at', + ] + read_only_fields = ['gear_type', 'status', 'created_at'] + + +class FirearmSerializer(serializers.ModelSerializer): + caliber = serializers.PrimaryKeyRelatedField( + queryset=Caliber.objects.filter(status='VERIFIED'), + required=False, + allow_null=True, + ) + caliber_detail = serializers.SerializerMethodField() + + def get_caliber_detail(self, obj): + if obj.caliber_id: + return CaliberMinSerializer(obj.caliber).data + return None + + class Meta: + model = Firearm + fields = [ + 'id', 'brand', 'model_name', 'description', + 'gear_type', 'status', + 'firearm_type', 'caliber', 'caliber_detail', + 'barrel_length_mm', 'magazine_capacity', + 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', + ] + read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'caliber_detail'] + + +class ScopeSerializer(serializers.ModelSerializer): + class Meta: + model = Scope + fields = [ + 'id', 'brand', 'model_name', 'description', + 'gear_type', 'status', + 'magnification_min', 'magnification_max', + 'objective_diameter_mm', 'tube_diameter_mm', 'reticle_type', + 'adjustment_unit', 'focal_plane', + 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', + ] + read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at'] + + +class SuppressorSerializer(serializers.ModelSerializer): + max_caliber = serializers.PrimaryKeyRelatedField( + queryset=Caliber.objects.filter(status='VERIFIED'), + required=False, + allow_null=True, + ) + max_caliber_detail = serializers.SerializerMethodField() + + def get_max_caliber_detail(self, obj): + if obj.max_caliber_id: + return CaliberMinSerializer(obj.max_caliber).data + return None + + class Meta: + model = Suppressor + fields = [ + 'id', 'brand', 'model_name', 'description', + 'gear_type', 'status', + 'max_caliber', 'max_caliber_detail', 'thread_pitch', 'length_mm', 'weight_g', + 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', + ] + read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'max_caliber_detail'] + + +class BipodSerializer(serializers.ModelSerializer): + class Meta: + model = Bipod + fields = [ + 'id', 'brand', 'model_name', 'description', + 'gear_type', 'status', + 'min_height_mm', 'max_height_mm', 'attachment_type', + 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', + ] + read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at'] + + +class MagazineSerializer(serializers.ModelSerializer): + caliber = serializers.PrimaryKeyRelatedField( + queryset=Caliber.objects.filter(status='VERIFIED'), + required=False, + allow_null=True, + ) + caliber_detail = serializers.SerializerMethodField() + + def get_caliber_detail(self, obj): + if obj.caliber_id: + return CaliberMinSerializer(obj.caliber).data + return None + + class Meta: + model = Magazine + fields = [ + 'id', 'brand', 'model_name', 'description', + 'gear_type', 'status', + 'caliber', 'caliber_detail', 'capacity', + 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', + ] + read_only_fields = ['gear_type', 'status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'caliber_detail'] + + +# Maps gear_type discriminator → serializer class +_GEAR_SERIALIZER_MAP = { + 'FIREARM': FirearmSerializer, + 'SCOPE': ScopeSerializer, + 'SUPPRESSOR': SuppressorSerializer, + 'BIPOD': BipodSerializer, + 'MAGAZINE': MagazineSerializer, +} + + +class PolymorphicGearSerializer(serializers.BaseSerializer): + """ + Read-only serializer that dispatches to the correct typed serializer + based on gear_type. Used when embedding gear details in nested responses. + """ + def to_representation(self, instance): + concrete = get_concrete_gear(instance) + serializer_cls = _GEAR_SERIALIZER_MAP.get(instance.gear_type, GearBaseSerializer) + return serializer_cls(concrete, context=self.context).data + + +# ── User inventory serializers ──────────────────────────────────────────────── + +class UserGearSerializer(serializers.ModelSerializer): + # Write: accept a gear FK (VERIFIED or user's own PENDING) + gear = serializers.PrimaryKeyRelatedField( + queryset=Gear.objects.none(), # narrowed in __init__ + write_only=True, + ) + # Read: return full typed gear details + gear_detail = PolymorphicGearSerializer(source='gear', read_only=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + from django.db.models import Q + request = self.context.get('request') + if request and request.user.is_authenticated: + self.fields['gear'].queryset = Gear.objects.filter( + Q(status=GearStatus.VERIFIED) | + Q(status=GearStatus.PENDING, submitted_by=request.user) + ) + else: + self.fields['gear'].queryset = Gear.objects.filter(status=GearStatus.VERIFIED) + + class Meta: + model = UserGear + fields = [ + 'id', + 'gear', # write + 'gear_detail', # read + 'nickname', 'serial_number', 'purchase_date', 'notes', + 'added_at', + ] + read_only_fields = ['added_at'] + + +# ── Rig serializers ─────────────────────────────────────────────────────────── + +class RigItemReadSerializer(serializers.ModelSerializer): + user_gear = UserGearSerializer(read_only=True) + + class Meta: + model = RigItem + fields = ['id', 'user_gear', 'role'] + + +class RigItemCreateSerializer(serializers.ModelSerializer): + """Used when adding an item to a rig (POST /rigs/{id}/items/).""" + user_gear = serializers.PrimaryKeyRelatedField( + queryset=UserGear.objects.none() # narrowed to request.user in __init__ + ) + + class Meta: + model = RigItem + fields = ['id', 'user_gear', 'role'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + if request and request.user.is_authenticated: + self.fields['user_gear'].queryset = UserGear.objects.filter( + user=request.user + ) + + def validate(self, attrs): + rig = self.context['rig'] + # Build a temporary instance for clean() validation + instance = RigItem(rig=rig, **attrs) + instance.clean() + return attrs + + def create(self, validated_data): + rig = self.context['rig'] + return RigItem.objects.create(rig=rig, **validated_data) + + +class RigSerializer(serializers.ModelSerializer): + rig_items = RigItemReadSerializer(many=True, read_only=True) + primary_caliber = serializers.SerializerMethodField() + + def get_primary_caliber(self, obj): + for item in obj.rig_items.all(): + if item.role == 'PRIMARY': + gear = item.user_gear.gear + # Firearm caliber lives on the MTI child table + try: + firearm = gear.firearm + if firearm.caliber_id: + return {'id': firearm.caliber_id, 'name': firearm.caliber.name} + return None + except Exception: + return None + return None + + class Meta: + model = Rig + fields = ['id', 'name', 'description', 'is_public', 'primary_caliber', 'rig_items', 'created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at'] + + +# ── Ammo catalog serializer ─────────────────────────────────────────────────── + +class AmmoSerializer(serializers.ModelSerializer): + caliber = serializers.PrimaryKeyRelatedField( + queryset=Caliber.objects.filter(status='VERIFIED'), + required=False, + allow_null=True, + ) + caliber_detail = serializers.SerializerMethodField() + + def get_caliber_detail(self, obj): + if obj.caliber_id: + return CaliberMinSerializer(obj.caliber).data + return None + + class Meta: + model = Ammo + fields = [ + 'id', 'brand', 'name', 'caliber', 'caliber_detail', + 'bullet_weight_gr', 'bullet_type', + 'primer_size', 'case_material', + 'muzzle_velocity_fps', 'muzzle_energy_ftlb', + 'box_count', 'notes', 'status', + 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', + ] + read_only_fields = ['status', 'submitted_by', 'reviewed_by', 'reviewed_at', 'created_at', 'caliber_detail'] + + +# ── Reloading component serializers ────────────────────────────────────────── + +class PrimerSerializer(serializers.ModelSerializer): + class Meta: + model = Primer + fields = ['id', 'brand', 'name', 'size', 'notes', 'status', 'submitted_by'] + read_only_fields = ['status', 'submitted_by'] + + +class BrassSerializer(serializers.ModelSerializer): + caliber = serializers.PrimaryKeyRelatedField( + queryset=Caliber.objects.filter(status='VERIFIED'), + required=False, + allow_null=True, + ) + caliber_detail = serializers.SerializerMethodField() + + def get_caliber_detail(self, obj): + if obj.caliber_id: + return CaliberMinSerializer(obj.caliber).data + return None + + class Meta: + model = Brass + fields = ['id', 'brand', 'caliber', 'caliber_detail', 'primer_pocket', 'trim_length_mm', 'notes', 'status', 'submitted_by'] + read_only_fields = ['status', 'submitted_by', 'caliber_detail'] + + +class BulletSerializer(serializers.ModelSerializer): + class Meta: + model = Bullet + fields = [ + 'id', 'brand', 'model_name', 'weight_gr', 'bullet_type', + 'diameter_mm', 'length_mm', 'bc_g1', 'bc_g7', + 'status', 'submitted_by', + ] + read_only_fields = ['status', 'submitted_by'] + + +class PowderSerializer(serializers.ModelSerializer): + class Meta: + model = Powder + fields = ['id', 'brand', 'name', 'powder_type', 'burn_rate_index', 'notes', 'status', 'submitted_by'] + read_only_fields = ['status', 'submitted_by'] + + +# ── Reload development serializers ──────────────────────────────────────────── + +class ReloadedAmmoBatchSerializer(serializers.ModelSerializer): + powder_detail = PowderSerializer(source='powder', read_only=True) + + class Meta: + model = ReloadedAmmoBatch + fields = [ + 'id', 'recipe', + 'powder', # write (PK) + 'powder_detail', # read (nested) + 'powder_charge_gr', 'quantity', + 'oal_mm', 'coal_mm', 'crimp', + 'case_prep_notes', 'notes', 'loaded_at', + 'created_at', 'updated_at', + ] + read_only_fields = ['created_at', 'updated_at'] + + +class ReloadRecipeSerializer(serializers.ModelSerializer): + caliber = serializers.PrimaryKeyRelatedField( + queryset=Caliber.objects.filter(status='VERIFIED'), + required=False, + allow_null=True, + ) + caliber_detail = serializers.SerializerMethodField() + primer_detail = PrimerSerializer(source='primer', read_only=True) + brass_detail = BrassSerializer(source='brass', read_only=True) + bullet_detail = BulletSerializer(source='bullet', read_only=True) + batches = ReloadedAmmoBatchSerializer(many=True, read_only=True) + + def get_caliber_detail(self, obj): + if obj.caliber_id: + return CaliberMinSerializer(obj.caliber).data + return None + + class Meta: + model = ReloadRecipe + fields = [ + 'id', 'name', 'caliber', 'caliber_detail', + 'primer', # write + 'primer_detail', # read + 'brass', + 'brass_detail', + 'bullet', + 'bullet_detail', + 'notes', 'is_public', 'batches', + 'created_at', 'updated_at', + ] + read_only_fields = ['created_at', 'updated_at', 'caliber_detail'] diff --git a/apps/gears/urls.py b/apps/gears/urls.py new file mode 100644 index 0000000..34ee6ce --- /dev/null +++ b/apps/gears/urls.py @@ -0,0 +1,51 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + AmmoViewSet, + BipodViewSet, + BrassViewSet, + BulletViewSet, + FirearmViewSet, + MagazineViewSet, + PowderViewSet, + PrimerViewSet, + ReloadedAmmoBatchViewSet, + ReloadRecipeViewSet, + RigViewSet, + ScopeViewSet, + SuppressorViewSet, + UserGearViewSet, +) + +router = DefaultRouter() + +# Gear catalog — per type +router.register(r'gears/firearms', FirearmViewSet, basename='firearm') +router.register(r'gears/scopes', ScopeViewSet, basename='scope') +router.register(r'gears/suppressors', SuppressorViewSet, basename='suppressor') +router.register(r'gears/bipods', BipodViewSet, basename='bipod') +router.register(r'gears/magazines', MagazineViewSet, basename='magazine') + +# Ammo catalog +router.register(r'gears/ammo', AmmoViewSet, basename='ammo') + +# Reloading components (admin CRUD, read-only for users) +router.register(r'gears/components/primers', PrimerViewSet, basename='primer') +router.register(r'gears/components/brass', BrassViewSet, basename='brass') +router.register(r'gears/components/bullets', BulletViewSet, basename='bullet') +router.register(r'gears/components/powders', PowderViewSet, basename='powder') + +# User inventory +router.register(r'inventory', UserGearViewSet, basename='usergear') + +# Rigs +router.register(r'rigs', RigViewSet, basename='rig') + +# Reload development (user-owned) +router.register(r'reloading/recipes', ReloadRecipeViewSet, basename='reload-recipe') +router.register(r'reloading/batches', ReloadedAmmoBatchViewSet, basename='reload-batch') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/gears/views.py b/apps/gears/views.py new file mode 100644 index 0000000..8288fbc --- /dev/null +++ b/apps/gears/views.py @@ -0,0 +1,387 @@ +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.response import Response + +from .models import ( + Ammo, + Bipod, + Brass, + Bullet, + Firearm, + GearStatus, + Magazine, + Powder, + Primer, + ReloadedAmmoBatch, + ReloadRecipe, + Rig, + RigItem, + Scope, + Suppressor, + UserGear, +) +from .permissions import IsAdminOrReadOnly +from .serializers import ( + AmmoSerializer, + BipodSerializer, + BrassSerializer, + BulletSerializer, + FirearmSerializer, + MagazineSerializer, + PowderSerializer, + PrimerSerializer, + ReloadedAmmoBatchSerializer, + ReloadRecipeSerializer, + RigItemCreateSerializer, + RigItemReadSerializer, + RigSerializer, + ScopeSerializer, + SuppressorSerializer, + UserGearSerializer, +) + + +# ── Gear catalog — shared mixin ─────────────────────────────────────────────── + +class GearCatalogMixin: + """ + Behaviour shared across all per-type gear viewsets. + + - GET list/retrieve: authenticated users see only VERIFIED entries; + staff see everything. + - POST: any authenticated user may submit a new entry (status=PENDING). + Staff submissions are auto-verified. + - PUT/PATCH/DELETE: staff only. + - POST .../verify/ or .../reject/: staff only. + """ + + def get_queryset(self): + from django.db.models import Q + if self.request.user.is_staff: + return self.queryset.all() + return self.queryset.filter( + Q(status=GearStatus.VERIFIED) | + Q(status=GearStatus.PENDING, submitted_by=self.request.user) + ) + + def get_permissions(self): + if self.action in ('update', 'partial_update', 'destroy'): + return [IsAdminUser()] + return [IsAuthenticated()] + + def perform_create(self, serializer): + if self.request.user.is_staff: + serializer.save( + status=GearStatus.VERIFIED, + reviewed_by=self.request.user, + reviewed_at=timezone.now(), + ) + else: + serializer.save( + status=GearStatus.PENDING, + submitted_by=self.request.user, + ) + + @action(detail=True, methods=['post'], permission_classes=[IsAdminUser]) + def verify(self, request, pk=None): + gear = self.get_object() + gear.verify(reviewed_by=request.user) + return Response(self.get_serializer(gear).data) + + @action(detail=True, methods=['post'], permission_classes=[IsAdminUser]) + def reject(self, request, pk=None): + gear = self.get_object() + gear.reject(reviewed_by=request.user) + return Response(self.get_serializer(gear).data) + + +# ── Per-type gear viewsets ──────────────────────────────────────────────────── + +class FirearmViewSet(GearCatalogMixin, viewsets.ModelViewSet): + queryset = Firearm.objects.select_related('submitted_by', 'reviewed_by', 'caliber') + serializer_class = FirearmSerializer + search_fields = ['brand', 'model_name', 'caliber__name'] + ordering_fields = ['brand', 'model_name', 'caliber__name', 'created_at'] + filterset_fields = ['firearm_type', 'caliber', 'status'] + + +class ScopeViewSet(GearCatalogMixin, viewsets.ModelViewSet): + queryset = Scope.objects.select_related('submitted_by', 'reviewed_by') + serializer_class = ScopeSerializer + search_fields = ['brand', 'model_name', 'reticle_type'] + ordering_fields = ['brand', 'model_name', 'magnification_max', 'created_at'] + filterset_fields = ['reticle_type', 'status'] + + +class SuppressorViewSet(GearCatalogMixin, viewsets.ModelViewSet): + queryset = Suppressor.objects.select_related('submitted_by', 'reviewed_by', 'max_caliber') + serializer_class = SuppressorSerializer + search_fields = ['brand', 'model_name', 'max_caliber__name'] + ordering_fields = ['brand', 'model_name', 'created_at'] + filterset_fields = ['max_caliber', 'status'] + + +class BipodViewSet(GearCatalogMixin, viewsets.ModelViewSet): + queryset = Bipod.objects.select_related('submitted_by', 'reviewed_by') + serializer_class = BipodSerializer + search_fields = ['brand', 'model_name'] + ordering_fields = ['brand', 'model_name', 'created_at'] + filterset_fields = ['attachment_type', 'status'] + + +class MagazineViewSet(GearCatalogMixin, viewsets.ModelViewSet): + queryset = Magazine.objects.select_related('submitted_by', 'reviewed_by', 'caliber') + serializer_class = MagazineSerializer + search_fields = ['brand', 'model_name', 'caliber__name'] + ordering_fields = ['brand', 'model_name', 'caliber__name', 'created_at'] + filterset_fields = ['caliber', 'status'] + + +# ── User inventory ──────────────────────────────────────────────────────────── + +class UserGearViewSet(viewsets.ModelViewSet): + """ + The authenticated user's personal gear inventory. + Each item links a catalog Gear to the user with optional personal metadata. + """ + serializer_class = UserGearSerializer + permission_classes = [IsAuthenticated] + pagination_class = None + search_fields = ['nickname', 'serial_number', 'gear__brand', 'gear__model_name'] + ordering_fields = ['added_at', 'nickname'] + filterset_fields = ['gear__gear_type'] + + def get_queryset(self): + return ( + UserGear.objects + .filter(user=self.request.user) + .select_related('gear') + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +# ── Rigs ────────────────────────────────────────────────────────────────────── + +class RigViewSet(viewsets.ModelViewSet): + """ + The authenticated user's loadout rigs. + + Items are managed via nested endpoints: + POST /rigs/{id}/items/ → add a UserGear to the rig + DELETE /rigs/{id}/items/{item_id}/ → remove an item from the rig + """ + serializer_class = RigSerializer + permission_classes = [IsAuthenticated] + pagination_class = None + search_fields = ['name', 'description'] + ordering_fields = ['name', 'created_at'] + + def get_queryset(self): + return ( + Rig.objects + .filter(user=self.request.user) + .prefetch_related('rig_items__user_gear__gear__firearm') + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + @action(detail=True, methods=['post'], url_path='items') + def add_item(self, request, pk=None): + rig = self.get_object() + serializer = RigItemCreateSerializer( + data=request.data, + context={'request': request, 'rig': rig}, + ) + serializer.is_valid(raise_exception=True) + item = serializer.save() + return Response( + RigItemReadSerializer(item, context={'request': request}).data, + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=['delete'], + url_path=r'items/(?P[^/.]+)', + ) + def remove_item(self, request, pk=None, item_pk=None): + rig = self.get_object() + item = get_object_or_404(RigItem, pk=item_pk, rig=rig) + item.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +# ── Ammo catalog ────────────────────────────────────────────────────────────── + +class AmmoViewSet(GearCatalogMixin, viewsets.ModelViewSet): + """ + Commercial/factory ammunition catalog. + Same moderation flow as gear types: users submit PENDING, staff verify/reject. + """ + queryset = Ammo.objects.select_related('submitted_by', 'reviewed_by', 'caliber') + serializer_class = AmmoSerializer + search_fields = ['brand', 'name', 'caliber__name'] + ordering_fields = ['brand', 'name', 'caliber__name', 'bullet_weight_gr', 'created_at'] + filterset_fields = ['bullet_type', 'caliber', 'primer_size', 'case_material', 'status'] + + +# ── Reloading components ────────────────────────────────────────────────────── + +class ComponentViewSetMixin: + """ + Any authenticated user may submit a component (status=PENDING). + Staff submissions are auto-verified. Only staff may update/delete or verify/reject. + """ + pagination_class = None + + def get_queryset(self): + from django.db.models import Q + qs = super().get_queryset() + if self.request.user.is_staff: + return qs + return qs.filter( + Q(status=GearStatus.VERIFIED) | + Q(status=GearStatus.PENDING, submitted_by=self.request.user) + ) + + def get_permissions(self): + if self.action in ('update', 'partial_update', 'destroy'): + return [IsAdminUser()] + return [IsAuthenticated()] + + def perform_create(self, serializer): + if self.request.user.is_staff: + serializer.save(status=GearStatus.VERIFIED, submitted_by=self.request.user) + else: + serializer.save(status=GearStatus.PENDING, submitted_by=self.request.user) + + @action(detail=True, methods=['post'], permission_classes=[IsAdminUser]) + def verify(self, request, pk=None): + obj = self.get_object() + obj.verify() + return Response(self.get_serializer(obj).data) + + @action(detail=True, methods=['post'], permission_classes=[IsAdminUser]) + def reject(self, request, pk=None): + obj = self.get_object() + obj.reject() + return Response(self.get_serializer(obj).data) + + +class PrimerViewSet(ComponentViewSetMixin, viewsets.ModelViewSet): + queryset = Primer.objects.all() + serializer_class = PrimerSerializer + search_fields = ['brand', 'name'] + ordering_fields = ['brand', 'name'] + filterset_fields = ['size'] + + +class BrassViewSet(ComponentViewSetMixin, viewsets.ModelViewSet): + queryset = Brass.objects.select_related('caliber') + serializer_class = BrassSerializer + search_fields = ['brand', 'caliber__name'] + ordering_fields = ['brand', 'caliber__name'] + filterset_fields = ['caliber', 'primer_pocket'] + + +class BulletViewSet(ComponentViewSetMixin, viewsets.ModelViewSet): + queryset = Bullet.objects.all() + serializer_class = BulletSerializer + search_fields = ['brand', 'model_name'] + ordering_fields = ['brand', 'model_name', 'weight_gr'] + filterset_fields = ['bullet_type'] + + +class PowderViewSet(ComponentViewSetMixin, viewsets.ModelViewSet): + queryset = Powder.objects.all() + serializer_class = PowderSerializer + search_fields = ['brand', 'name'] + ordering_fields = ['brand', 'name', 'burn_rate_index'] + filterset_fields = ['powder_type'] + + +# ── Reload development ──────────────────────────────────────────────────────── + +class ReloadRecipeViewSet(viewsets.ModelViewSet): + """ + User's reload recipes (fixed primer + brass + bullet combinations). + Batches (different powder charges) are created via /reloading/batches/. + """ + serializer_class = ReloadRecipeSerializer + permission_classes = [IsAuthenticated] + pagination_class = None + search_fields = ['name', 'caliber__name'] + ordering_fields = ['name', 'caliber__name', 'created_at'] + filterset_fields = ['caliber'] + + def get_queryset(self): + return ( + ReloadRecipe.objects + .filter(user=self.request.user) + .select_related('primer', 'brass', 'bullet') + .prefetch_related('batches__powder') + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + @action(detail=True, methods=['get'], url_path='stats') + def stats(self, request, pk=None): + """ + Per-batch velocity statistics for all batches in this recipe. + Useful for comparing powder charges and identifying the optimal load. + """ + # Local imports to avoid circular module-level dependency tools ↔ gears + from apps.tools.models import Shot + from apps.tools.serializers import _compute_stats + + recipe = self.get_object() + result = [] + for batch in recipe.batches.select_related('powder').prefetch_related( + 'shot_groups__shots' + ): + all_shots = Shot.objects.filter(group__ammo_batch=batch) + result.append({ + 'batch_id': batch.pk, + 'powder': str(batch.powder), + 'powder_charge_gr': str(batch.powder_charge_gr), + 'stats': _compute_stats(all_shots), + }) + return Response(result) + + +class ReloadedAmmoBatchViewSet(viewsets.ModelViewSet): + """ + Individual powder charge batches under a recipe. + Filter by recipe using ?recipe=. + """ + serializer_class = ReloadedAmmoBatchSerializer + permission_classes = [IsAuthenticated] + pagination_class = None + search_fields = ['notes', 'powder__name', 'powder__brand'] + ordering_fields = ['powder_charge_gr', 'loaded_at', 'created_at'] + filterset_fields = ['recipe', 'powder'] + + def get_queryset(self): + return ( + ReloadedAmmoBatch.objects + .filter(recipe__user=self.request.user) + .select_related('recipe', 'powder') + ) + + @action(detail=True, methods=['get'], url_path='stats') + def stats(self, request, pk=None): + """Velocity statistics for all ShotGroups linked to this batch.""" + # Local imports to avoid circular module-level dependency tools ↔ gears + from apps.tools.models import Shot + from apps.tools.serializers import _compute_stats + + batch = self.get_object() + all_shots = Shot.objects.filter(group__ammo_batch=batch) + return Response(_compute_stats(all_shots)) diff --git a/apps/photos/__init__.py b/apps/photos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/photos/admin.py b/apps/photos/admin.py new file mode 100644 index 0000000..ccdab8d --- /dev/null +++ b/apps/photos/admin.py @@ -0,0 +1,50 @@ +from django.contrib import admin +from django.utils.html import format_html + +from .models import GroupPhoto, GroupPhotoAnalysis, Photo, PointOfImpact + + +@admin.register(Photo) +class PhotoAdmin(admin.ModelAdmin): + list_display = ('id', 'content_type', 'size_kb', 'width', 'height', 'uploaded_by', 'uploaded_at') + search_fields = ('uploaded_by__email', 'description', 'content_type') + readonly_fields = ('size', 'width', 'height', 'uploaded_at', 'preview') + raw_id_fields = ('uploaded_by',) + + def size_kb(self, obj): + return f'{obj.size / 1024:.1f} KB' + size_kb.short_description = 'Size' + + def preview(self, obj): + return format_html( + '', + obj.pk, + ) + preview.short_description = 'Preview' + + def get_fields(self, request, obj=None): + fields = ['content_type', 'size', 'width', 'height', 'uploaded_by', 'description', 'uploaded_at'] + if obj: + fields.insert(0, 'preview') + return fields + + +class GroupPhotoAnalysisInline(admin.StackedInline): + model = GroupPhotoAnalysis + extra = 0 + max_num = 1 + + +class PointOfImpactInline(admin.TabularInline): + model = PointOfImpact + extra = 0 + fields = ('order', 'shot', 'x_px', 'y_px', 'x_mm', 'y_mm', 'radius_mm', 'notes') + raw_id_fields = ('shot',) + + +@admin.register(GroupPhoto) +class GroupPhotoAdmin(admin.ModelAdmin): + list_display = ('id', 'shot_group', 'caption', 'order') + search_fields = ('caption', 'shot_group__label', 'shot_group__analysis__name') + raw_id_fields = ('photo', 'shot_group') + inlines = [GroupPhotoAnalysisInline, PointOfImpactInline] diff --git a/apps/photos/analysis.py b/apps/photos/analysis.py new file mode 100644 index 0000000..3d71d2a --- /dev/null +++ b/apps/photos/analysis.py @@ -0,0 +1,64 @@ +""" +Group size computation from PointOfImpact real-world coordinates. + +All measurements in millimetres. Origin is point-of-aim: + x > 0 = right (windage), y > 0 = up (elevation). +""" +import math + + +def compute_group_size( + points: list[tuple[float, float]], + distance_m: float | None = None, +) -> dict: + """ + Compute ballistic group metrics from a list of (x_mm, y_mm) coordinates. + + Args: + points: list of (x_mm, y_mm) tuples — minimum 2 required. + distance_m: shooting distance in metres, used for MOA conversion. + Pass None to leave MOA fields as None. + + Returns: + dict with keys matching GroupPhotoAnalysis fields. + """ + if len(points) < 2: + raise ValueError("At least 2 points of impact are required.") + + xs = [p[0] for p in points] + ys = [p[1] for p in points] + n = len(points) + + # Extreme spread: maximum pairwise distance + group_size_mm = 0.0 + for i in range(n): + for j in range(i + 1, n): + d = math.sqrt((xs[i] - xs[j]) ** 2 + (ys[i] - ys[j]) ** 2) + if d > group_size_mm: + group_size_mm = d + + # Centroid + cx = sum(xs) / n + cy = sum(ys) / n + + # Mean radius: average distance from centroid + mean_radius_mm = sum( + math.sqrt((x - cx) ** 2 + (y - cy) ** 2) for x, y in points + ) / n + + def to_moa(mm: float) -> float | None: + """Convert mm at distance_m to MOA. 1 MOA ≈ 0.29089 mm/m at that distance.""" + if distance_m is None or distance_m <= 0: + return None + return round(mm / (distance_m * 0.29089), 3) + + return { + 'group_size_mm': round(group_size_mm, 2), + 'group_size_moa': to_moa(group_size_mm), + 'mean_radius_mm': round(mean_radius_mm, 2), + 'mean_radius_moa': to_moa(mean_radius_mm), + 'windage_offset_mm': round(cx, 2), + 'windage_offset_moa': to_moa(cx), + 'elevation_offset_mm': round(cy, 2), + 'elevation_offset_moa': to_moa(cy), + } diff --git a/apps/photos/apps.py b/apps/photos/apps.py new file mode 100644 index 0000000..215428d --- /dev/null +++ b/apps/photos/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PhotosConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.photos' + verbose_name = 'Photos' diff --git a/apps/photos/migrations/0001_initial.py b/apps/photos/migrations/0001_initial.py new file mode 100644 index 0000000..91fb842 --- /dev/null +++ b/apps/photos/migrations/0001_initial.py @@ -0,0 +1,100 @@ +# Generated by Django 4.2.16 on 2026-03-25 10:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GroupPhoto', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('caption', models.CharField(blank=True, max_length=255, verbose_name='caption')), + ('order', models.PositiveSmallIntegerField(default=0, verbose_name='order')), + ], + options={ + 'verbose_name': 'group photo', + 'verbose_name_plural': 'group photos', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='PointOfImpact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveSmallIntegerField(default=0, help_text='1-based sequence; used when shot FK is absent.', verbose_name='order')), + ('x_px', models.PositiveSmallIntegerField(verbose_name='x (px)')), + ('y_px', models.PositiveSmallIntegerField(verbose_name='y (px)')), + ('x_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='x offset (mm)')), + ('y_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='y offset (mm)')), + ('radius_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='bullet hole radius (mm)')), + ('notes', models.CharField(blank=True, max_length=255, verbose_name='notes')), + ('group_photo', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='points_of_impact', to='photos.groupphoto', verbose_name='group photo')), + ('shot', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='point_of_impact', to='tools.shot', verbose_name='shot')), + ], + options={ + 'verbose_name': 'point of impact', + 'verbose_name_plural': 'points of impact', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='Photo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.BinaryField(verbose_name='data')), + ('content_type', models.CharField(max_length=50, verbose_name='content type')), + ('size', models.PositiveIntegerField(verbose_name='size (bytes)')), + ('width', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='width (px)')), + ('height', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='height (px)')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='uploaded at')), + ('description', models.CharField(blank=True, max_length=255, verbose_name='description')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='photos', to=settings.AUTH_USER_MODEL, verbose_name='uploaded by')), + ], + options={ + 'verbose_name': 'photo', + 'verbose_name_plural': 'photos', + 'ordering': ['-uploaded_at'], + }, + ), + migrations.CreateModel( + name='GroupPhotoAnalysis', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('group_size_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='group size (mm)')), + ('group_size_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='group size (MOA)')), + ('elevation_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='elevation offset (mm)')), + ('elevation_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='elevation offset (MOA)')), + ('windage_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='windage offset (mm)')), + ('windage_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='windage offset (MOA)')), + ('mean_radius_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='mean radius (mm)')), + ('mean_radius_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='mean radius (MOA)')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('group_photo', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='analysis', to='photos.groupphoto', verbose_name='group photo')), + ], + options={ + 'verbose_name': 'group photo analysis', + 'verbose_name_plural': 'group photo analyses', + }, + ), + migrations.AddField( + model_name='groupphoto', + name='photo', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='group_photo', to='photos.photo', verbose_name='photo'), + ), + migrations.AddField( + model_name='groupphoto', + name='shot_group', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_photos', to='tools.shotgroup', verbose_name='shot group'), + ), + ] diff --git a/apps/photos/migrations/0002_groupphoto_nullable_shotgroup.py b/apps/photos/migrations/0002_groupphoto_nullable_shotgroup.py new file mode 100644 index 0000000..2d3204c --- /dev/null +++ b/apps/photos/migrations/0002_groupphoto_nullable_shotgroup.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2026-03-30 13:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tools', '0004_shotgroup_nullable_analysis_user_ammo'), + ('photos', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='groupphoto', + name='shot_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='group_photos', to='tools.shotgroup', verbose_name='shot group'), + ), + ] diff --git a/apps/photos/migrations/0003_groupphoto_is_public.py b/apps/photos/migrations/0003_groupphoto_is_public.py new file mode 100644 index 0000000..461cdc5 --- /dev/null +++ b/apps/photos/migrations/0003_groupphoto_is_public.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('photos', '0002_groupphoto_nullable_shotgroup'), + ] + + operations = [ + migrations.AddField( + model_name='groupphoto', + name='is_public', + field=models.BooleanField(default=False, verbose_name='public'), + ), + ] diff --git a/apps/photos/migrations/__init__.py b/apps/photos/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/photos/models.py b/apps/photos/models.py new file mode 100644 index 0000000..6abba64 --- /dev/null +++ b/apps/photos/models.py @@ -0,0 +1,172 @@ +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}" diff --git a/apps/photos/serializers.py b/apps/photos/serializers.py new file mode 100644 index 0000000..aed49f0 --- /dev/null +++ b/apps/photos/serializers.py @@ -0,0 +1,93 @@ +from rest_framework import serializers + +# Cross-app import (photos → tools). String FK in the model avoids DB circular +# import; here we need the class directly for the serializer queryset. +from apps.tools.models import ShotGroup + +from .models import GroupPhoto, GroupPhotoAnalysis, Photo, PointOfImpact + + +class PhotoMetaSerializer(serializers.ModelSerializer): + """Photo metadata only — the binary `data` field is never exposed in JSON.""" + + class Meta: + model = Photo + fields = ['id', 'content_type', 'size', 'width', 'height', + 'uploaded_by', 'uploaded_at', 'description'] + read_only_fields = ['id', 'size', 'width', 'height', 'uploaded_by', 'uploaded_at'] + + +class GroupPhotoAnalysisSerializer(serializers.ModelSerializer): + class Meta: + model = GroupPhotoAnalysis + fields = [ + 'group_size_mm', 'group_size_moa', + 'elevation_offset_mm', 'elevation_offset_moa', + 'windage_offset_mm', 'windage_offset_moa', + 'mean_radius_mm', 'mean_radius_moa', + 'notes', + ] + + def validate(self, attrs): + instance = GroupPhotoAnalysis(**attrs) + instance.clean() + return attrs + + +class PointOfImpactSerializer(serializers.ModelSerializer): + # Write: accept Shot PK; Read: compact inline summary + shot_detail = serializers.SerializerMethodField() + + class Meta: + model = PointOfImpact + fields = [ + 'id', 'order', + 'shot', 'shot_detail', + 'x_px', 'y_px', + 'x_mm', 'y_mm', 'radius_mm', + 'notes', + ] + + def get_shot_detail(self, obj): + if not obj.shot_id: + return None + return { + 'id': obj.shot.pk, + 'shot_number': obj.shot.shot_number, + 'velocity_fps': str(obj.shot.velocity_fps), + } + + +class GroupPhotoSerializer(serializers.ModelSerializer): + photo = PhotoMetaSerializer(read_only=True) + photo_id = serializers.PrimaryKeyRelatedField( + source='photo', + queryset=Photo.objects.all(), + write_only=True, + ) + # shot_group is optional — photos can exist independently of any group + shot_group = serializers.PrimaryKeyRelatedField( + queryset=ShotGroup.objects.all(), + required=False, allow_null=True, + ) + shot_group_detail = serializers.SerializerMethodField() + analysis = GroupPhotoAnalysisSerializer(read_only=True) + points_of_impact = PointOfImpactSerializer(many=True, read_only=True) + + class Meta: + model = GroupPhoto + fields = [ + 'id', 'photo_id', 'photo', + 'shot_group', 'shot_group_detail', 'caption', 'order', + 'is_public', 'analysis', 'points_of_impact', + ] + + def get_shot_group_detail(self, obj): + if not obj.shot_group_id: + return None + sg = obj.shot_group + return { + 'id': sg.pk, + 'label': sg.label, + 'distance_m': str(sg.distance_m) if sg.distance_m else None, + } diff --git a/apps/photos/urls.py b/apps/photos/urls.py new file mode 100644 index 0000000..d09d31a --- /dev/null +++ b/apps/photos/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import GroupPhotoViewSet, PhotoUploadView, photo_data_view + +router = DefaultRouter() +router.register(r'group-photos', GroupPhotoViewSet, basename='group-photo') + +urlpatterns = [ + path('upload/', PhotoUploadView.as_view(), name='photo-upload'), + path('/data/', photo_data_view, name='photo-data'), + path('', include(router.urls)), +] diff --git a/apps/photos/views.py b/apps/photos/views.py new file mode 100644 index 0000000..6e8b102 --- /dev/null +++ b/apps/photos/views.py @@ -0,0 +1,226 @@ +from io import BytesIO + +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from PIL import Image as PillowImage +from rest_framework import parsers, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.response import Response +from rest_framework.views import APIView + +# Reuse the same permission used by ChronographAnalysis (anonymous-friendly) +from apps.tools.permissions import IsOwnerOrUnclaimed + + +class IsGroupPhotoOwner(BasePermission): + """ + For GroupPhoto objects, ownership is via photo.uploaded_by. + Read is open; mutations require being the uploader (or anonymous upload). + """ + def has_permission(self, request, view): + return True + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + uploader = obj.photo.uploaded_by if obj.photo_id else None + if uploader is None: + return True + return request.user.is_authenticated and uploader == request.user + +from .analysis import compute_group_size as _compute_group_size +from .models import GroupPhoto, GroupPhotoAnalysis, Photo, PointOfImpact +from .serializers import ( + GroupPhotoAnalysisSerializer, + GroupPhotoSerializer, + PhotoMetaSerializer, + PointOfImpactSerializer, +) + +# Allowed image MIME types +_ALLOWED_CONTENT_TYPES = {'image/jpeg', 'image/png', 'image/webp', 'image/gif'} + + +class PhotoUploadView(APIView): + """ + POST multipart/form-data with a 'file' field → create a Photo. + + Optional fields: description (string). + Returns PhotoMetaSerializer data (no binary). + Anonymous uploads are allowed (same pattern as ChronographAnalysis). + """ + parser_classes = [parsers.MultiPartParser] + permission_classes = [IsOwnerOrUnclaimed] + + def post(self, request): + f = request.FILES.get('file') + if not f: + return Response({'detail': 'No file provided.'}, status=status.HTTP_400_BAD_REQUEST) + + if f.content_type not in _ALLOWED_CONTENT_TYPES: + return Response( + {'detail': f'Unsupported file type: {f.content_type}. ' + f'Allowed: {", ".join(sorted(_ALLOWED_CONTENT_TYPES))}'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + raw = f.read() + try: + img = PillowImage.open(BytesIO(raw)) + img.verify() # check it is a valid image + img = PillowImage.open(BytesIO(raw)) # re-open after verify (verify closes the stream) + width, height = img.size + except Exception: + return Response({'detail': 'Invalid or corrupt image file.'}, status=status.HTTP_400_BAD_REQUEST) + + photo = Photo.objects.create( + data=raw, + content_type=f.content_type, + size=len(raw), + width=width, + height=height, + uploaded_by=request.user if request.user.is_authenticated else None, + description=request.data.get('description', ''), + ) + return Response(PhotoMetaSerializer(photo).data, status=status.HTTP_201_CREATED) + + +def photo_data_view(request, pk): + """ + GET /api/photos/{pk}/data/ → serve the raw image bytes. + Public — no authentication required. + """ + photo = get_object_or_404(Photo, pk=pk) + return HttpResponse(bytes(photo.data), content_type=photo.content_type) + + +class GroupPhotoViewSet(viewsets.ModelViewSet): + """ + CRUD for GroupPhoto objects. + + Filter by ?shot_group= to list photos for a specific shot group. + + Nested sub-resources: + GET/PUT/PATCH .../analysis/ upsert GroupPhotoAnalysis + GET/POST .../points/ list / add PointOfImpact + PATCH/DELETE .../points/{poi_pk}/ update / remove a PointOfImpact + """ + serializer_class = GroupPhotoSerializer + permission_classes = [IsGroupPhotoOwner] + + def get_queryset(self): + qs = GroupPhoto.objects.select_related('photo', 'analysis').prefetch_related('points_of_impact') + shot_group = self.request.query_params.get('shot_group') + if shot_group: + qs = qs.filter(shot_group_id=shot_group) + return qs + + # ── Analysis ────────────────────────────────────────────────────────────── + + @action(detail=True, methods=['get', 'put', 'patch'], url_path='analysis') + def analysis(self, request, pk=None): + group_photo = self.get_object() + + if request.method == 'GET': + try: + serializer = GroupPhotoAnalysisSerializer(group_photo.analysis) + return Response(serializer.data) + except GroupPhotoAnalysis.DoesNotExist: + return Response({}, status=status.HTTP_200_OK) + + # PUT / PATCH — upsert + partial = request.method == 'PATCH' + try: + instance = group_photo.analysis + except GroupPhotoAnalysis.DoesNotExist: + instance = None + + serializer = GroupPhotoAnalysisSerializer( + instance, + data=request.data, + partial=partial, + ) + serializer.is_valid(raise_exception=True) + serializer.save(group_photo=group_photo) + return Response(serializer.data) + + # ── Points of impact ────────────────────────────────────────────────────── + + @action(detail=True, methods=['get', 'post'], url_path='points') + def points(self, request, pk=None): + group_photo = self.get_object() + + if request.method == 'GET': + pois = group_photo.points_of_impact.all() + return Response(PointOfImpactSerializer(pois, many=True).data) + + serializer = PointOfImpactSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + poi = serializer.save(group_photo=group_photo) + return Response(PointOfImpactSerializer(poi).data, status=status.HTTP_201_CREATED) + + @action( + detail=True, + methods=['patch', 'delete'], + url_path=r'points/(?P[^/.]+)', + ) + def point_detail(self, request, pk=None, poi_pk=None): + group_photo = self.get_object() + poi = get_object_or_404(PointOfImpact, pk=poi_pk, group_photo=group_photo) + + if request.method == 'DELETE': + poi.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + serializer = PointOfImpactSerializer(poi, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + # ── Group size computation ───────────────────────────────────────────────── + + @action(detail=True, methods=['post'], url_path='compute-group-size') + def compute_group_size(self, request, pk=None): + """ + Compute GroupPhotoAnalysis metrics from PointOfImpact real-world + coordinates (x_mm, y_mm). Requires ≥ 2 POIs with mm coordinates set. + Upserts the GroupPhotoAnalysis and returns the updated record. + """ + group_photo = self.get_object() + + pois = list( + group_photo.points_of_impact + .filter(x_mm__isnull=False, y_mm__isnull=False) + .values_list('x_mm', 'y_mm') + ) + + if len(pois) < 2: + return Response( + {'detail': 'At least 2 points of impact with real-world ' + 'coordinates (x_mm, y_mm) are required.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + distance_m = None + if group_photo.shot_group_id and group_photo.shot_group.distance_m: + distance_m = float(group_photo.shot_group.distance_m) + + metrics = _compute_group_size( + [(float(x), float(y)) for x, y in pois], + distance_m=distance_m, + ) + + try: + instance = group_photo.analysis + except GroupPhotoAnalysis.DoesNotExist: + instance = None + + serializer = GroupPhotoAnalysisSerializer( + instance, + data=metrics, + partial=bool(instance), + ) + serializer.is_valid(raise_exception=True) + serializer.save(group_photo=group_photo) + return Response(serializer.data) diff --git a/apps/sessions/__init__.py b/apps/sessions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/sessions/admin.py b/apps/sessions/admin.py new file mode 100644 index 0000000..44bc5fb --- /dev/null +++ b/apps/sessions/admin.py @@ -0,0 +1,36 @@ +from django.contrib import admin + +from .models import FreePracticeSession, PRSSession, PRSStage, SpeedShootingSession + + +class PRSStageInline(admin.TabularInline): + model = PRSStage + extra = 0 + fields = [ + 'order', 'position', 'distance_m', + 'target_width_cm', 'target_height_cm', 'max_time_s', 'shots_count', + 'actual_elevation', 'actual_windage', + 'hits', 'score', 'time_taken_s', + ] + + +@admin.register(PRSSession) +class PRSSessionAdmin(admin.ModelAdmin): + list_display = ['__str__', 'user', 'date', 'location', 'competition_name', 'category'] + list_filter = ['date'] + search_fields = ['user__email', 'competition_name', 'location'] + inlines = [PRSStageInline] + + +@admin.register(FreePracticeSession) +class FreePracticeSessionAdmin(admin.ModelAdmin): + list_display = ['__str__', 'user', 'date', 'location', 'distance_m', 'rounds_fired'] + list_filter = ['date'] + search_fields = ['user__email', 'name', 'location'] + + +@admin.register(SpeedShootingSession) +class SpeedShootingSessionAdmin(admin.ModelAdmin): + list_display = ['__str__', 'user', 'date', 'format', 'rounds_fired'] + list_filter = ['date'] + search_fields = ['user__email', 'name', 'format'] diff --git a/apps/sessions/apps.py b/apps/sessions/apps.py new file mode 100644 index 0000000..b576a46 --- /dev/null +++ b/apps/sessions/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SessionsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.sessions' + label = 'shooting_sessions' diff --git a/apps/sessions/ballistics.py b/apps/sessions/ballistics.py new file mode 100644 index 0000000..4e68ea6 --- /dev/null +++ b/apps/sessions/ballistics.py @@ -0,0 +1,32 @@ +""" +Ballistic correction engine. + +Computes scope elevation and windage corrections given a rig, ammo, +target distance, and weather conditions. + +Currently a stub — returns None until the trajectory integration is built. +""" + + +def compute_corrections(session, stage) -> dict: + """ + Return scope corrections for a given PRS stage. + + Args: + session: PRSSession instance (provides rig, ammo/reloaded_batch, weather) + stage: PRSStage instance (provides distance_m) + + Returns: + dict with keys: elevation, windage, unit, message + """ + # TODO: implement point-mass trajectory integration using: + # - session.rig.zero_distance_m, session.rig.scope_height_mm + # - ammo BC (Bullet.bc_g7 / bc_g1) and muzzle velocity + # - session weather fields (temperature_c, pressure_hpa, humidity_pct) + # - stage.distance_m and session wind fields + return { + 'elevation': None, + 'windage': None, + 'unit': None, + 'message': 'Ballistic engine not yet implemented.', + } diff --git a/apps/sessions/migrations/0001_initial.py b/apps/sessions/migrations/0001_initial.py new file mode 100644 index 0000000..fdc6246 --- /dev/null +++ b/apps/sessions/migrations/0001_initial.py @@ -0,0 +1,144 @@ +# Generated by Django 4.2.16 on 2026-03-30 09:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('gears', '0011_rig_ballistic_fields'), + ('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PRSSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=255, verbose_name='name')), + ('date', models.DateField(verbose_name='date')), + ('location', models.CharField(blank=True, max_length=255, verbose_name='location')), + ('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')), + ('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')), + ('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')), + ('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')), + ('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')), + ('weather_notes', models.TextField(blank=True, verbose_name='weather notes')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('competition_name', models.CharField(blank=True, max_length=255, verbose_name='competition name')), + ('category', models.CharField(blank=True, max_length=100, verbose_name='category')), + ('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')), + ('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')), + ('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'PRS session', + 'verbose_name_plural': 'PRS sessions', + 'ordering': ['-date', '-created_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SpeedShootingSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=255, verbose_name='name')), + ('date', models.DateField(verbose_name='date')), + ('location', models.CharField(blank=True, max_length=255, verbose_name='location')), + ('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')), + ('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')), + ('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')), + ('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')), + ('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')), + ('weather_notes', models.TextField(blank=True, verbose_name='weather notes')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('format', models.CharField(blank=True, max_length=100, verbose_name='format')), + ('rounds_fired', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='rounds fired')), + ('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')), + ('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')), + ('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'speed shooting session', + 'verbose_name_plural': 'speed shooting sessions', + 'ordering': ['-date', '-created_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='PRSStage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveSmallIntegerField(verbose_name='order')), + ('position', models.CharField(choices=[('PRONE', 'Prone'), ('STANDING', 'Standing'), ('SITTING', 'Sitting'), ('KNEELING', 'Kneeling'), ('BARRICADE', 'Barricade'), ('UNSUPPORTED', 'Unsupported'), ('OTHER', 'Other')], default='PRONE', max_length=20, verbose_name='shooting position')), + ('distance_m', models.PositiveSmallIntegerField(verbose_name='distance (m)')), + ('target_width_cm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='target width (cm)')), + ('target_height_cm', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='target height (cm)')), + ('max_time_s', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='max time (s)')), + ('shots_count', models.PositiveSmallIntegerField(default=1, verbose_name='shots count')), + ('notes_prep', models.TextField(blank=True, verbose_name='prep notes')), + ('computed_elevation', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='computed elevation')), + ('computed_windage', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='computed windage')), + ('correction_unit', models.CharField(blank=True, choices=[('MOA', 'MOA'), ('MRAD', 'MRAD'), ('CLICK', 'Clicks')], max_length=10, verbose_name='correction unit')), + ('actual_elevation', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='actual elevation')), + ('actual_windage', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='actual windage')), + ('hits', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='hits')), + ('score', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='score')), + ('time_taken_s', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='time taken (s)')), + ('notes_post', models.TextField(blank=True, verbose_name='post notes')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stages', to='shooting_sessions.prssession', verbose_name='session')), + ('shot_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prs_stages', to='tools.shotgroup', verbose_name='shot group')), + ], + options={ + 'verbose_name': 'PRS stage', + 'verbose_name_plural': 'PRS stages', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='FreePracticeSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=255, verbose_name='name')), + ('date', models.DateField(verbose_name='date')), + ('location', models.CharField(blank=True, max_length=255, verbose_name='location')), + ('temperature_c', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='temperature (°C)')), + ('wind_speed_ms', models.DecimalField(blank=True, decimal_places=1, max_digits=5, null=True, verbose_name='wind speed (m/s)')), + ('wind_direction_deg', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='wind direction (°)')), + ('humidity_pct', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='humidity (%)')), + ('pressure_hpa', models.DecimalField(blank=True, decimal_places=1, max_digits=6, null=True, verbose_name='pressure (hPa)')), + ('weather_notes', models.TextField(blank=True, verbose_name='weather notes')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('distance_m', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='distance (m)')), + ('target_description', models.CharField(blank=True, max_length=255, verbose_name='target description')), + ('rounds_fired', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='rounds fired')), + ('ammo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.ammo', verbose_name='factory ammo')), + ('reloaded_batch', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.reloadedammobatch', verbose_name='reloaded batch')), + ('rig', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='gears.rig', verbose_name='rig')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'free practice session', + 'verbose_name_plural': 'free practice sessions', + 'ordering': ['-date', '-created_at'], + 'abstract': False, + }, + ), + migrations.AddConstraint( + model_name='prsstage', + constraint=models.UniqueConstraint(fields=('session', 'order'), name='unique_prs_stage_order'), + ), + ] diff --git a/apps/sessions/migrations/0002_add_analysis_fk.py b/apps/sessions/migrations/0002_add_analysis_fk.py new file mode 100644 index 0000000..9afdfeb --- /dev/null +++ b/apps/sessions/migrations/0002_add_analysis_fk.py @@ -0,0 +1,46 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shooting_sessions', '0001_initial'), + ('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'), + ] + + operations = [ + migrations.AddField( + model_name='prssession', + name='analysis', + field=models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='tools.chronographanalysis', + verbose_name='chronograph analysis', + ), + ), + migrations.AddField( + model_name='freepracticesession', + name='analysis', + field=models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='tools.chronographanalysis', + verbose_name='chronograph analysis', + ), + ), + migrations.AddField( + model_name='speedshootingsession', + name='analysis', + field=models.ForeignKey( + blank=True, null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='tools.chronographanalysis', + verbose_name='chronograph analysis', + ), + ), + ] diff --git a/apps/sessions/migrations/0003_session_is_public.py b/apps/sessions/migrations/0003_session_is_public.py new file mode 100644 index 0000000..64ce7a8 --- /dev/null +++ b/apps/sessions/migrations/0003_session_is_public.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shooting_sessions', '0002_add_analysis_fk'), + ] + + operations = [ + migrations.AddField( + model_name='prssession', + name='is_public', + field=models.BooleanField(default=False, verbose_name='public'), + ), + migrations.AddField( + model_name='freepracticesession', + name='is_public', + field=models.BooleanField(default=False, verbose_name='public'), + ), + migrations.AddField( + model_name='speedshootingsession', + name='is_public', + field=models.BooleanField(default=False, verbose_name='public'), + ), + ] diff --git a/apps/sessions/migrations/__init__.py b/apps/sessions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/sessions/models.py b/apps/sessions/models.py new file mode 100644 index 0000000..b45b22e --- /dev/null +++ b/apps/sessions/models.py @@ -0,0 +1,246 @@ +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})" diff --git a/apps/sessions/serializers.py b/apps/sessions/serializers.py new file mode 100644 index 0000000..33bf96f --- /dev/null +++ b/apps/sessions/serializers.py @@ -0,0 +1,301 @@ +from django.db.models import Q +from rest_framework import serializers + +from apps.common.serializer_helpers import ammo_detail, batch_detail +from apps.gears.models import Ammo, GearStatus, ReloadedAmmoBatch, Rig +from apps.tools.models import ChronographAnalysis, ShotGroup + +from .models import ( + CorrectionUnit, + FreePracticeSession, + PRSSession, + PRSStage, + SpeedShootingSession, +) + + +# ── Shared helpers ──────────────────────────────────────────────────────────── + +def _rig_detail(rig): + if rig is None: + return None + return {'id': rig.id, 'name': rig.name} + + +# ── Abstract write mixin ────────────────────────────────────────────────────── + +def _analysis_detail(analysis): + if analysis is None: + return None + return {'id': analysis.id, 'name': analysis.name, 'date': str(analysis.date) if analysis.date else None} + + +class AbstractSessionWriteMixin: + """ + Shared __init__ for all session write serializers: + narrows FK querysets to the current user. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + if request and request.user.is_authenticated: + self.fields['rig'].queryset = Rig.objects.filter(user=request.user) + self.fields['reloaded_batch'].queryset = ReloadedAmmoBatch.objects.filter( + recipe__user=request.user + ) + self.fields['analysis'].queryset = ChronographAnalysis.objects.filter( + Q(user=request.user) | Q(user__isnull=True) + ) + + def validate(self, attrs): + user = self.context['request'].user + instance = self.Meta.model(user=user, **attrs) + instance.clean() + return attrs + + +# ── PRS session ─────────────────────────────────────────────────────────────── + +class PRSStageSerializer(serializers.ModelSerializer): + shot_group = serializers.PrimaryKeyRelatedField( + queryset=ShotGroup.objects.none(), + required=False, allow_null=True, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + if request and request.user.is_authenticated: + self.fields['shot_group'].queryset = ShotGroup.objects.filter( + analysis__user=request.user + ) + + class Meta: + model = PRSStage + fields = [ + 'id', 'order', 'position', + 'distance_m', 'target_width_cm', 'target_height_cm', + 'max_time_s', 'shots_count', 'notes_prep', + 'computed_elevation', 'computed_windage', 'correction_unit', + 'actual_elevation', 'actual_windage', + 'hits', 'score', 'time_taken_s', + 'shot_group', 'notes_post', + ] + read_only_fields = ['computed_elevation', 'computed_windage', 'correction_unit'] + + +class PRSSessionListSerializer(serializers.ModelSerializer): + class Meta: + model = PRSSession + fields = [ + 'id', 'name', 'competition_name', 'category', + 'date', 'location', 'rig', 'is_public', 'created_at', + ] + + +class PRSSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer): + rig = serializers.PrimaryKeyRelatedField( + queryset=Rig.objects.none(), + required=False, allow_null=True, + ) + ammo = serializers.PrimaryKeyRelatedField( + queryset=Ammo.objects.filter(status=GearStatus.VERIFIED), + required=False, allow_null=True, + ) + reloaded_batch = serializers.PrimaryKeyRelatedField( + queryset=ReloadedAmmoBatch.objects.none(), + required=False, allow_null=True, + ) + analysis = serializers.PrimaryKeyRelatedField( + queryset=ChronographAnalysis.objects.none(), + required=False, allow_null=True, + ) + + class Meta: + model = PRSSession + fields = [ + 'id', 'name', 'competition_name', 'category', + 'date', 'location', + 'rig', 'ammo', 'reloaded_batch', 'analysis', + 'temperature_c', 'wind_speed_ms', 'wind_direction_deg', + 'humidity_pct', 'pressure_hpa', 'weather_notes', + 'notes', 'is_public', + ] + + +class PRSSessionDetailSerializer(serializers.ModelSerializer): + rig_detail = serializers.SerializerMethodField() + ammo_detail = serializers.SerializerMethodField() + reloaded_batch_detail = serializers.SerializerMethodField() + analysis_detail = serializers.SerializerMethodField() + stages = PRSStageSerializer(many=True, read_only=True) + + def get_rig_detail(self, obj): + return _rig_detail(obj.rig) + + def get_ammo_detail(self, obj): + return ammo_detail(obj.ammo) + + def get_reloaded_batch_detail(self, obj): + return batch_detail(obj.reloaded_batch) + + def get_analysis_detail(self, obj): + return _analysis_detail(obj.analysis) + + class Meta: + model = PRSSession + fields = [ + 'id', 'name', 'competition_name', 'category', + 'date', 'location', + 'rig', 'rig_detail', + 'ammo', 'ammo_detail', + 'reloaded_batch', 'reloaded_batch_detail', + 'analysis', 'analysis_detail', + 'temperature_c', 'wind_speed_ms', 'wind_direction_deg', + 'humidity_pct', 'pressure_hpa', 'weather_notes', + 'notes', 'is_public', 'stages', + 'created_at', 'updated_at', + ] + + +# ── Free Practice session ───────────────────────────────────────────────────── + +class FreePracticeSessionListSerializer(serializers.ModelSerializer): + class Meta: + model = FreePracticeSession + fields = ['id', 'name', 'date', 'location', 'distance_m', 'rounds_fired', 'rig', 'is_public', 'created_at'] + + +class FreePracticeSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer): + rig = serializers.PrimaryKeyRelatedField( + queryset=Rig.objects.none(), + required=False, allow_null=True, + ) + ammo = serializers.PrimaryKeyRelatedField( + queryset=Ammo.objects.filter(status=GearStatus.VERIFIED), + required=False, allow_null=True, + ) + reloaded_batch = serializers.PrimaryKeyRelatedField( + queryset=ReloadedAmmoBatch.objects.none(), + required=False, allow_null=True, + ) + analysis = serializers.PrimaryKeyRelatedField( + queryset=ChronographAnalysis.objects.none(), + required=False, allow_null=True, + ) + + class Meta: + model = FreePracticeSession + fields = [ + 'id', 'name', 'date', 'location', + 'rig', 'ammo', 'reloaded_batch', 'analysis', + 'distance_m', 'target_description', 'rounds_fired', + 'temperature_c', 'wind_speed_ms', 'wind_direction_deg', + 'humidity_pct', 'pressure_hpa', 'weather_notes', + 'notes', 'is_public', + ] + + +class FreePracticeSessionDetailSerializer(serializers.ModelSerializer): + rig_detail = serializers.SerializerMethodField() + ammo_detail = serializers.SerializerMethodField() + reloaded_batch_detail = serializers.SerializerMethodField() + analysis_detail = serializers.SerializerMethodField() + + def get_rig_detail(self, obj): + return _rig_detail(obj.rig) + + def get_ammo_detail(self, obj): + return ammo_detail(obj.ammo) + + def get_reloaded_batch_detail(self, obj): + return batch_detail(obj.reloaded_batch) + + def get_analysis_detail(self, obj): + return _analysis_detail(obj.analysis) + + class Meta: + model = FreePracticeSession + fields = [ + 'id', 'name', 'date', 'location', + 'rig', 'rig_detail', + 'ammo', 'ammo_detail', + 'reloaded_batch', 'reloaded_batch_detail', + 'analysis', 'analysis_detail', + 'distance_m', 'target_description', 'rounds_fired', + 'temperature_c', 'wind_speed_ms', 'wind_direction_deg', + 'humidity_pct', 'pressure_hpa', 'weather_notes', + 'notes', 'is_public', 'created_at', 'updated_at', + ] + + +# ── Speed Shooting session ──────────────────────────────────────────────────── + +class SpeedShootingSessionListSerializer(serializers.ModelSerializer): + class Meta: + model = SpeedShootingSession + fields = ['id', 'name', 'format', 'date', 'location', 'rounds_fired', 'rig', 'is_public', 'created_at'] + + +class SpeedShootingSessionWriteSerializer(AbstractSessionWriteMixin, serializers.ModelSerializer): + rig = serializers.PrimaryKeyRelatedField( + queryset=Rig.objects.none(), + required=False, allow_null=True, + ) + ammo = serializers.PrimaryKeyRelatedField( + queryset=Ammo.objects.filter(status=GearStatus.VERIFIED), + required=False, allow_null=True, + ) + reloaded_batch = serializers.PrimaryKeyRelatedField( + queryset=ReloadedAmmoBatch.objects.none(), + required=False, allow_null=True, + ) + analysis = serializers.PrimaryKeyRelatedField( + queryset=ChronographAnalysis.objects.none(), + required=False, allow_null=True, + ) + + class Meta: + model = SpeedShootingSession + fields = [ + 'id', 'name', 'format', 'date', 'location', + 'rig', 'ammo', 'reloaded_batch', 'analysis', + 'rounds_fired', + 'temperature_c', 'wind_speed_ms', 'wind_direction_deg', + 'humidity_pct', 'pressure_hpa', 'weather_notes', + 'notes', 'is_public', + ] + + +class SpeedShootingSessionDetailSerializer(serializers.ModelSerializer): + rig_detail = serializers.SerializerMethodField() + ammo_detail = serializers.SerializerMethodField() + reloaded_batch_detail = serializers.SerializerMethodField() + analysis_detail = serializers.SerializerMethodField() + + def get_rig_detail(self, obj): + return _rig_detail(obj.rig) + + def get_ammo_detail(self, obj): + return ammo_detail(obj.ammo) + + def get_reloaded_batch_detail(self, obj): + return batch_detail(obj.reloaded_batch) + + def get_analysis_detail(self, obj): + return _analysis_detail(obj.analysis) + + class Meta: + model = SpeedShootingSession + fields = [ + 'id', 'name', 'format', 'date', 'location', + 'rig', 'rig_detail', + 'ammo', 'ammo_detail', + 'reloaded_batch', 'reloaded_batch_detail', + 'analysis', 'analysis_detail', + 'rounds_fired', + 'temperature_c', 'wind_speed_ms', 'wind_direction_deg', + 'humidity_pct', 'pressure_hpa', 'weather_notes', + 'notes', 'is_public', 'created_at', 'updated_at', + ] diff --git a/apps/sessions/urls.py b/apps/sessions/urls.py new file mode 100644 index 0000000..0b8971a --- /dev/null +++ b/apps/sessions/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import FreePracticeSessionViewSet, PRSSessionViewSet, SpeedShootingSessionViewSet + +router = DefaultRouter() +router.register(r'sessions/prs', PRSSessionViewSet, basename='prs-session') +router.register(r'sessions/free-practice', FreePracticeSessionViewSet, basename='free-practice-session') +router.register(r'sessions/speed-shooting', SpeedShootingSessionViewSet, basename='speed-shooting-session') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/sessions/views.py b/apps/sessions/views.py new file mode 100644 index 0000000..a21d2d5 --- /dev/null +++ b/apps/sessions/views.py @@ -0,0 +1,143 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .ballistics import compute_corrections +from .models import FreePracticeSession, PRSSession, PRSStage, SpeedShootingSession +from .serializers import ( + FreePracticeSessionDetailSerializer, + FreePracticeSessionListSerializer, + FreePracticeSessionWriteSerializer, + PRSSessionDetailSerializer, + PRSSessionListSerializer, + PRSSessionWriteSerializer, + PRSStageSerializer, + SpeedShootingSessionDetailSerializer, + SpeedShootingSessionListSerializer, + SpeedShootingSessionWriteSerializer, +) + + +# ── PRS ─────────────────────────────────────────────────────────────────────── + +class PRSSessionViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + filterset_fields = ['date', 'is_public'] + search_fields = ['name', 'location', 'notes', 'competition_name'] + ordering_fields = ['date', 'created_at'] + + def get_queryset(self): + qs = ( + PRSSession.objects + .filter(user=self.request.user) + .select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis') + ) + if self.action != 'list': + qs = qs.prefetch_related('stages') + return qs + + def get_serializer_class(self): + if self.action == 'list': + return PRSSessionListSerializer + if self.action in ('create', 'update', 'partial_update'): + return PRSSessionWriteSerializer + return PRSSessionDetailSerializer + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + # ── Nested stage actions ────────────────────────────────────────────────── + + @action(detail=True, methods=['get', 'post'], url_path='stages') + def stages(self, request, pk=None): + session = self.get_object() + if request.method == 'GET': + serializer = PRSStageSerializer( + session.stages.all(), many=True, context={'request': request} + ) + return Response(serializer.data) + serializer = PRSStageSerializer( + data=request.data, + context={'request': request, 'session': session}, + ) + serializer.is_valid(raise_exception=True) + serializer.save(session=session) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['get', 'patch', 'delete'], + url_path=r'stages/(?P[^/.]+)') + def stage_detail(self, request, pk=None, stage_pk=None): + session = self.get_object() + stage = get_object_or_404(PRSStage, pk=stage_pk, session=session) + if request.method == 'GET': + return Response(PRSStageSerializer(stage, context={'request': request}).data) + if request.method == 'PATCH': + serializer = PRSStageSerializer( + stage, data=request.data, partial=True, context={'request': request} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + stage.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=['post'], + url_path=r'stages/(?P[^/.]+)/compute-corrections') + def compute_corrections_action(self, request, pk=None, stage_pk=None): + session = self.get_object() + stage = get_object_or_404(PRSStage, pk=stage_pk, session=session) + return Response(compute_corrections(session, stage)) + + +# ── Free Practice ───────────────────────────────────────────────────────────── + +class FreePracticeSessionViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + filterset_fields = ['date', 'is_public'] + search_fields = ['name', 'location', 'notes'] + ordering_fields = ['date', 'created_at'] + + def get_queryset(self): + return ( + FreePracticeSession.objects + .filter(user=self.request.user) + .select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis') + ) + + def get_serializer_class(self): + if self.action == 'list': + return FreePracticeSessionListSerializer + if self.action in ('create', 'update', 'partial_update'): + return FreePracticeSessionWriteSerializer + return FreePracticeSessionDetailSerializer + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +# ── Speed Shooting ──────────────────────────────────────────────────────────── + +class SpeedShootingSessionViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + filterset_fields = ['date', 'is_public'] + search_fields = ['name', 'location', 'notes', 'format'] + ordering_fields = ['date', 'created_at'] + + def get_queryset(self): + return ( + SpeedShootingSession.objects + .filter(user=self.request.user) + .select_related('rig', 'ammo', 'reloaded_batch__recipe', 'reloaded_batch__powder', 'analysis') + ) + + def get_serializer_class(self): + if self.action == 'list': + return SpeedShootingSessionListSerializer + if self.action in ('create', 'update', 'partial_update'): + return SpeedShootingSessionWriteSerializer + return SpeedShootingSessionDetailSerializer + + def perform_create(self, serializer): + serializer.save(user=self.request.user) diff --git a/apps/social/__init__.py b/apps/social/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/social/admin.py b/apps/social/admin.py new file mode 100644 index 0000000..561a0d6 --- /dev/null +++ b/apps/social/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin + +from .models import BlogPost, Bug, Friendship, Message + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ('sender', 'recipient', 'subject', 'sent_at', 'read_at') + list_filter = ('sent_at',) + search_fields = ('sender__email', 'recipient__email', 'subject') + readonly_fields = ('sent_at', 'read_at') + + +@admin.register(BlogPost) +class BlogPostAdmin(admin.ModelAdmin): + list_display = ('author', 'title', 'is_public', 'created_at') + list_filter = ('is_public',) + search_fields = ('author__email', 'title') + + +@admin.register(Bug) +class BugAdmin(admin.ModelAdmin): + list_display = ('reporter', 'title', 'severity', 'status', 'created_at') + list_filter = ('severity', 'status') + search_fields = ('reporter__email', 'title') + readonly_fields = ('created_at', 'updated_at', 'resolved_at') + + +@admin.register(Friendship) +class FriendshipAdmin(admin.ModelAdmin): + list_display = ('from_user', 'to_user', 'status', 'created_at') + list_filter = ('status',) + search_fields = ('from_user__email', 'to_user__email') diff --git a/apps/social/apps.py b/apps/social/apps.py new file mode 100644 index 0000000..2f17459 --- /dev/null +++ b/apps/social/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SocialConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.social' diff --git a/apps/social/migrations/0001_initial.py b/apps/social/migrations/0001_initial.py new file mode 100644 index 0000000..8091fd6 --- /dev/null +++ b/apps/social/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.16 on 2026-04-01 19:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.CharField(max_length=200, verbose_name='subject')), + ('body', models.TextField(verbose_name='body')), + ('sent_at', models.DateTimeField(auto_now_add=True)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('deleted_by_sender', models.BooleanField(default=False)), + ('deleted_by_recipient', models.BooleanField(default=False)), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-sent_at'], + }, + ), + migrations.CreateModel( + name='Bug', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=300, verbose_name='title')), + ('description', models.TextField(verbose_name='description')), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='medium', max_length=10, verbose_name='severity')), + ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], default='open', max_length=15, verbose_name='status')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('reporter', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reported_bugs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='BlogPost', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=300, verbose_name='title')), + ('body', models.TextField(verbose_name='body')), + ('is_public', models.BooleanField(default=True, verbose_name='public')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blog_posts', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Friendship', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('blocked', 'Blocked')], default='pending', max_length=10, verbose_name='status')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('from_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendships_sent', to=settings.AUTH_USER_MODEL)), + ('to_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friendships_received', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'unique_together': {('from_user', 'to_user')}, + }, + ), + ] diff --git a/apps/social/migrations/__init__.py b/apps/social/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/social/models.py b/apps/social/models.py new file mode 100644 index 0000000..e702926 --- /dev/null +++ b/apps/social/models.py @@ -0,0 +1,111 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +# ── Message ─────────────────────────────────────────────────────────────────── + +class Message(models.Model): + sender = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_messages') + recipient = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='received_messages') + subject = models.CharField(_('subject'), max_length=200) + body = models.TextField(_('body')) + sent_at = models.DateTimeField(auto_now_add=True) + read_at = models.DateTimeField(null=True, blank=True) + deleted_by_sender = models.BooleanField(default=False) + deleted_by_recipient = models.BooleanField(default=False) + + class Meta: + ordering = ['-sent_at'] + + def __str__(self): + return f'{self.sender} → {self.recipient}: {self.subject}' + + +# ── BlogPost ────────────────────────────────────────────────────────────────── + +class BlogPost(models.Model): + author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='blog_posts') + title = models.CharField(_('title'), max_length=300) + body = models.TextField(_('body')) + is_public = models.BooleanField(_('public'), default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return self.title + + +# ── Bug ─────────────────────────────────────────────────────────────────────── + +class BugSeverity(models.TextChoices): + LOW = 'low', _('Low') + MEDIUM = 'medium', _('Medium') + HIGH = 'high', _('High') + CRITICAL = 'critical', _('Critical') + + +class BugStatus(models.TextChoices): + OPEN = 'open', _('Open') + IN_PROGRESS = 'in_progress', _('In Progress') + RESOLVED = 'resolved', _('Resolved') + CLOSED = 'closed', _('Closed') + + +class Bug(models.Model): + reporter = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, related_name='reported_bugs', + ) + title = models.CharField(_('title'), max_length=300) + description = models.TextField(_('description')) + severity = models.CharField( + _('severity'), max_length=10, + choices=BugSeverity.choices, default=BugSeverity.MEDIUM, + ) + status = models.CharField( + _('status'), max_length=15, + choices=BugStatus.choices, default=BugStatus.OPEN, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + resolved_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f'[{self.get_severity_display()}] {self.title}' + + +# ── Friendship ──────────────────────────────────────────────────────────────── + +class FriendshipStatus(models.TextChoices): + PENDING = 'pending', _('Pending') + ACCEPTED = 'accepted', _('Accepted') + BLOCKED = 'blocked', _('Blocked') + + +class Friendship(models.Model): + from_user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendships_sent', + ) + to_user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='friendships_received', + ) + status = models.CharField( + _('status'), max_length=10, + choices=FriendshipStatus.choices, default=FriendshipStatus.PENDING, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [('from_user', 'to_user')] + ordering = ['-created_at'] + + def __str__(self): + return f'{self.from_user} → {self.to_user} ({self.status})' diff --git a/apps/social/serializers.py b/apps/social/serializers.py new file mode 100644 index 0000000..1a568c7 --- /dev/null +++ b/apps/social/serializers.py @@ -0,0 +1,105 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from .models import BlogPost, Bug, Friendship, Message + +User = get_user_model() + + +def _user_mini(user): + if user is None: + return None + name = (f'{user.first_name} {user.last_name}'.strip()) or user.username + return {'id': user.id, 'username': user.username, 'display_name': name} + + +# ── Message ─────────────────────────────────────────────────────────────────── + +class MessageListSerializer(serializers.ModelSerializer): + sender_detail = serializers.SerializerMethodField() + recipient_detail = serializers.SerializerMethodField() + + def get_sender_detail(self, obj): return _user_mini(obj.sender) + def get_recipient_detail(self, obj): return _user_mini(obj.recipient) + + class Meta: + model = Message + fields = ['id', 'sender_detail', 'recipient_detail', 'subject', 'sent_at', 'read_at'] + + +class MessageDetailSerializer(serializers.ModelSerializer): + sender_detail = serializers.SerializerMethodField() + recipient_detail = serializers.SerializerMethodField() + + def get_sender_detail(self, obj): return _user_mini(obj.sender) + def get_recipient_detail(self, obj): return _user_mini(obj.recipient) + + class Meta: + model = Message + fields = ['id', 'sender_detail', 'recipient_detail', 'subject', 'body', 'sent_at', 'read_at'] + + +class MessageCreateSerializer(serializers.ModelSerializer): + recipient = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) + + class Meta: + model = Message + fields = ['recipient', 'subject', 'body'] + + def validate_recipient(self, value): + if value == self.context['request'].user: + raise serializers.ValidationError('You cannot send a message to yourself.') + return value + + +# ── BlogPost ────────────────────────────────────────────────────────────────── + +class BlogPostSerializer(serializers.ModelSerializer): + author_detail = serializers.SerializerMethodField() + + def get_author_detail(self, obj): return _user_mini(obj.author) + + class Meta: + model = BlogPost + fields = ['id', 'author_detail', 'title', 'body', 'is_public', 'created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at'] + + +# ── Bug ─────────────────────────────────────────────────────────────────────── + +class BugSerializer(serializers.ModelSerializer): + reporter_detail = serializers.SerializerMethodField() + + def get_reporter_detail(self, obj): return _user_mini(obj.reporter) + + class Meta: + model = Bug + fields = [ + 'id', 'reporter_detail', 'title', 'description', + 'severity', 'status', 'created_at', 'updated_at', 'resolved_at', + ] + read_only_fields = ['status', 'resolved_at', 'created_at', 'updated_at'] + + +# ── Friendship ──────────────────────────────────────────────────────────────── + +class FriendshipSerializer(serializers.ModelSerializer): + from_user_detail = serializers.SerializerMethodField() + to_user_detail = serializers.SerializerMethodField() + + def get_from_user_detail(self, obj): return _user_mini(obj.from_user) + def get_to_user_detail(self, obj): return _user_mini(obj.to_user) + + class Meta: + model = Friendship + fields = ['id', 'from_user_detail', 'to_user_detail', 'status', 'created_at'] + read_only_fields = ['status', 'created_at'] + + +class FriendRequestSerializer(serializers.Serializer): + to_user = serializers.PrimaryKeyRelatedField(queryset=User.objects.all()) + + def validate_to_user(self, value): + if value == self.context['request'].user: + raise serializers.ValidationError('You cannot send a friend request to yourself.') + return value diff --git a/apps/social/urls.py b/apps/social/urls.py new file mode 100644 index 0000000..d9b1e43 --- /dev/null +++ b/apps/social/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter + +from .views import BlogPostViewSet, BugViewSet, FriendshipViewSet, MessageViewSet, member_search + +router = DefaultRouter() +router.register('social/messages', MessageViewSet, basename='message') +router.register('social/blog', BlogPostViewSet, basename='blogpost') +router.register('social/bugs', BugViewSet, basename='bug') +router.register('social/friends', FriendshipViewSet, basename='friendship') + +urlpatterns = [ + path('social/members/', member_search, name='member-search'), +] + router.urls diff --git a/apps/social/views.py b/apps/social/views.py new file mode 100644 index 0000000..a990781 --- /dev/null +++ b/apps/social/views.py @@ -0,0 +1,262 @@ +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status, viewsets +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .models import BlogPost, Bug, BugStatus, Friendship, FriendshipStatus, Message +from .serializers import ( + BlogPostSerializer, + BugSerializer, + FriendRequestSerializer, + FriendshipSerializer, + MessageCreateSerializer, + MessageDetailSerializer, + MessageListSerializer, +) + +User = get_user_model() + + +# ── Member search (for adding friends) ─────────────────────────────────────── + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def member_search(request): + q = request.query_params.get('q', '').strip() + if len(q) < 2: + return Response([]) + users = ( + User.objects + .exclude(pk=request.user.pk) + .filter( + Q(username__icontains=q) | + Q(first_name__icontains=q) | + Q(last_name__icontains=q) + )[:20] + ) + data = [ + { + 'id': u.id, + 'username': u.username, + 'display_name': (f'{u.first_name} {u.last_name}'.strip()) or u.username, + } + for u in users + ] + return Response(data) + + +# ── Messages ────────────────────────────────────────────────────────────────── + +class MessageViewSet(viewsets.GenericViewSet): + permission_classes = [IsAuthenticated] + + def list(self, request): + """Inbox: messages received by the current user.""" + qs = ( + Message.objects + .filter(recipient=request.user, deleted_by_recipient=False) + .select_related('sender', 'recipient') + .order_by('-sent_at') + ) + return Response(MessageListSerializer(qs, many=True).data) + + def create(self, request): + ser = MessageCreateSerializer(data=request.data, context={'request': request}) + ser.is_valid(raise_exception=True) + msg = ser.save(sender=request.user) + return Response(MessageDetailSerializer(msg).data, status=status.HTTP_201_CREATED) + + def retrieve(self, request, pk=None): + user = request.user + msg = get_object_or_404( + Message, + Q(sender=user, deleted_by_sender=False) | + Q(recipient=user, deleted_by_recipient=False), + pk=pk, + ) + if msg.recipient == user and msg.read_at is None: + msg.read_at = timezone.now() + msg.save(update_fields=['read_at']) + return Response(MessageDetailSerializer(msg).data) + + def destroy(self, request, pk=None): + user = request.user + msg = get_object_or_404( + Message, + Q(sender=user) | Q(recipient=user), + pk=pk, + ) + if msg.sender == user: + msg.deleted_by_sender = True + if msg.recipient == user: + msg.deleted_by_recipient = True + msg.save(update_fields=['deleted_by_sender', 'deleted_by_recipient']) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=False, methods=['get'], url_path='sent') + def sent(self, request): + qs = ( + Message.objects + .filter(sender=request.user, deleted_by_sender=False) + .select_related('sender', 'recipient') + .order_by('-sent_at') + ) + return Response(MessageListSerializer(qs, many=True).data) + + @action(detail=False, methods=['get'], url_path='unread-count') + def unread_count(self, request): + count = Message.objects.filter( + recipient=request.user, read_at__isnull=True, deleted_by_recipient=False + ).count() + return Response({'unread': count}) + + +# ── BlogPost ────────────────────────────────────────────────────────────────── + +class BlogPostViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = BlogPostSerializer + search_fields = ['title', 'body'] + ordering_fields = ['created_at'] + + def get_queryset(self): + user = self.request.user + return ( + BlogPost.objects + .filter(Q(author=user) | Q(is_public=True)) + .select_related('author') + ) + + def perform_create(self, serializer): + serializer.save(author=self.request.user) + + def check_write_permission(self, instance): + from rest_framework.exceptions import PermissionDenied + if instance.author != self.request.user and not self.request.user.is_staff: + raise PermissionDenied + + def perform_update(self, serializer): + self.check_write_permission(self.get_object()) + serializer.save() + + def perform_destroy(self, instance): + self.check_write_permission(instance) + instance.delete() + + +# ── Bug ─────────────────────────────────────────────────────────────────────── + +class BugViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + serializer_class = BugSerializer + filterset_fields = ['severity', 'status'] + search_fields = ['title', 'description'] + ordering_fields = ['created_at', 'severity'] + + def get_queryset(self): + user = self.request.user + if user.is_staff: + return Bug.objects.select_related('reporter').all() + return Bug.objects.filter(reporter=user).select_related('reporter') + + def perform_create(self, serializer): + serializer.save(reporter=self.request.user) + + @action(detail=True, methods=['post'], url_path='resolve') + def resolve(self, request, pk=None): + from rest_framework.exceptions import PermissionDenied + if not request.user.is_staff: + raise PermissionDenied + bug = self.get_object() + bug.status = BugStatus.RESOLVED + bug.resolved_at = timezone.now() + bug.save(update_fields=['status', 'resolved_at', 'updated_at']) + return Response(BugSerializer(bug).data) + + +# ── Friendship ──────────────────────────────────────────────────────────────── + +class FriendshipViewSet(viewsets.GenericViewSet): + permission_classes = [IsAuthenticated] + serializer_class = FriendshipSerializer + + def list(self, request): + """Accepted friends.""" + user = request.user + qs = ( + Friendship.objects + .filter(Q(from_user=user) | Q(to_user=user), status=FriendshipStatus.ACCEPTED) + .select_related('from_user', 'to_user') + ) + return Response(FriendshipSerializer(qs, many=True).data) + + def create(self, request): + ser = FriendRequestSerializer(data=request.data, context={'request': request}) + ser.is_valid(raise_exception=True) + to_user = ser.validated_data['to_user'] + user = request.user + + existing = Friendship.objects.filter( + Q(from_user=user, to_user=to_user) | + Q(from_user=to_user, to_user=user) + ).first() + if existing: + return Response({'detail': 'A friendship or request already exists.'}, status=status.HTTP_409_CONFLICT) + + friendship = Friendship.objects.create(from_user=user, to_user=to_user) + return Response(FriendshipSerializer(friendship).data, status=status.HTTP_201_CREATED) + + def destroy(self, request, pk=None): + user = request.user + friendship = get_object_or_404( + Friendship, + Q(from_user=user) | Q(to_user=user), + pk=pk, + ) + friendship.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=False, methods=['get'], url_path='requests') + def requests(self, request): + """Incoming pending requests.""" + qs = ( + Friendship.objects + .filter(to_user=request.user, status=FriendshipStatus.PENDING) + .select_related('from_user', 'to_user') + ) + return Response(FriendshipSerializer(qs, many=True).data) + + @action(detail=False, methods=['get'], url_path='sent-requests') + def sent_requests(self, request): + """Outgoing pending requests.""" + qs = ( + Friendship.objects + .filter(from_user=request.user, status=FriendshipStatus.PENDING) + .select_related('from_user', 'to_user') + ) + return Response(FriendshipSerializer(qs, many=True).data) + + @action(detail=True, methods=['post'], url_path='accept') + def accept(self, request, pk=None): + friendship = get_object_or_404( + Friendship, pk=pk, to_user=request.user, status=FriendshipStatus.PENDING + ) + friendship.status = FriendshipStatus.ACCEPTED + friendship.save(update_fields=['status', 'updated_at']) + return Response(FriendshipSerializer(friendship).data) + + @action(detail=True, methods=['post'], url_path='decline') + def decline(self, request, pk=None): + """Decline an incoming request or cancel an outgoing one.""" + user = request.user + friendship = get_object_or_404( + Friendship, + Q(from_user=user) | Q(to_user=user), + pk=pk, status=FriendshipStatus.PENDING, + ) + friendship.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/tools/__init__.py b/apps/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tools/admin.py b/apps/tools/admin.py new file mode 100644 index 0000000..6400629 --- /dev/null +++ b/apps/tools/admin.py @@ -0,0 +1,45 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import ChronographAnalysis, Shot, ShotGroup + + +class ShotInline(admin.TabularInline): + model = Shot + extra = 0 + readonly_fields = ('shot_number',) + fields = ('shot_number', 'velocity_fps', 'notes') + + +class ShotGroupInline(admin.TabularInline): + model = ShotGroup + extra = 0 + show_change_link = True + fields = ('label', 'distance_m', 'order', 'ammo_batch', 'notes') + raw_id_fields = ('ammo_batch',) + + +@admin.register(ChronographAnalysis) +class ChronographAnalysisAdmin(admin.ModelAdmin): + list_display = ('name', 'user', 'date', 'created_at') + search_fields = ('name', 'user__email', 'notes') + readonly_fields = ('created_at', 'updated_at') + raw_id_fields = ('user',) + inlines = [ShotGroupInline] + + +@admin.register(ShotGroup) +class ShotGroupAdmin(admin.ModelAdmin): + list_display = ('label', 'analysis', 'distance_m', 'order', 'ammo_batch') + search_fields = ('label', 'analysis__name') + raw_id_fields = ('analysis', 'ammo_batch') + inlines = [ShotInline] + + +@admin.register(Shot) +class ShotAdmin(admin.ModelAdmin): + list_display = ('shot_number', 'group', 'velocity_fps', 'notes') + search_fields = ('group__label', 'group__analysis__name') + readonly_fields = ('shot_number',) + raw_id_fields = ('group',) + ordering = ('group', 'shot_number') diff --git a/apps/tools/analyzer/__init__.py b/apps/tools/analyzer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tools/analyzer/charts.py b/apps/tools/analyzer/charts.py new file mode 100644 index 0000000..d029515 --- /dev/null +++ b/apps/tools/analyzer/charts.py @@ -0,0 +1,82 @@ +import io +import base64 +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import pandas as pd + + +def render_group_charts(groups: list, y_min: float, y_max: float) -> list: + padding_fraction = 0.05 + y_range = y_max - y_min + if y_range == 0: + y_pad = 1.0 + else: + y_pad = y_range * padding_fraction + + charts = [] + for i, g in enumerate(groups): + fig, ax = plt.subplots(figsize=(9, 4)) + + x = g["time"] + y = g["speed"] + + ax.plot(x, y, marker="o", linewidth=1.5, markersize=5, color="#1f77b4") + + ax.set_ylim(y_min - y_pad, y_max + y_pad) + + ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) + fig.autofmt_xdate(rotation=30) + + ax.set_title(f"Group {i + 1} — {len(g)} shot(s)") + ax.set_xlabel("Time of Day") + ax.set_ylabel("Speed") + ax.grid(True, alpha=0.3) + fig.tight_layout() + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=100) + plt.close(fig) + buf.seek(0) + charts.append(base64.b64encode(buf.read()).decode("utf-8")) + + return charts + + +def render_overview_chart(group_stats: list) -> str: + """Dual-axis line chart: avg speed and avg std dev per group.""" + indices = [s["group_index"] for s in group_stats] + speeds = [s["mean_speed"] for s in group_stats] + stds = [s["std_speed"] if s["std_speed"] is not None else 0.0 for s in group_stats] + + fig, ax1 = plt.subplots(figsize=(7, 3)) + + color_speed = "#1f77b4" + color_std = "#d62728" + + ax1.plot(indices, speeds, marker="o", linewidth=1.8, markersize=5, + color=color_speed, label="Avg speed") + ax1.set_xlabel("Group") + ax1.set_ylabel("Avg speed", color=color_speed) + ax1.tick_params(axis="y", labelcolor=color_speed) + ax1.set_xticks(indices) + + ax2 = ax1.twinx() + ax2.plot(indices, stds, marker="s", linewidth=1.8, markersize=5, + color=color_std, linestyle="--", label="Avg std dev") + ax2.set_ylabel("Avg std dev", color=color_std) + ax2.tick_params(axis="y", labelcolor=color_std) + + lines1, labels1 = ax1.get_legend_handles_labels() + lines2, labels2 = ax2.get_legend_handles_labels() + ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=8, loc="upper right") + + ax1.grid(True, alpha=0.3) + fig.tight_layout() + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=100) + plt.close(fig) + buf.seek(0) + return base64.b64encode(buf.read()).decode("utf-8") diff --git a/apps/tools/analyzer/grouper.py b/apps/tools/analyzer/grouper.py new file mode 100644 index 0000000..baaeb26 --- /dev/null +++ b/apps/tools/analyzer/grouper.py @@ -0,0 +1,60 @@ +from datetime import timedelta +import pandas as pd + +OUTLIER_FACTOR = 5 + + +def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR, + manual_splits: list | None = None, + forced_splits: list | None = None) -> list: + """Split shots into groups. + + forced_splits: when provided, ONLY these split positions are used — auto-detection + is bypassed entirely. Use this for user-defined groupings from the visual editor. + + manual_splits: added on top of auto-detected splits (when forced_splits is None). + Both auto+manual mechanisms are merged and deduplicated. + """ + if len(df) <= 1: + return [df] + + def _build_groups(all_splits): + if not all_splits: + return [df] + groups = [] + prev = 0 + for pos in all_splits: + group = df.iloc[prev:pos] + if len(group) > 0: + groups.append(group.reset_index(drop=True)) + prev = pos + last = df.iloc[prev:] + if len(last) > 0: + groups.append(last.reset_index(drop=True)) + return groups + + # Forced mode: user controls exact split positions, no auto-detection + if forced_splits is not None: + valid = sorted(s for s in forced_splits if 0 < s < len(df)) + return _build_groups(valid) + + times = df["time"] + diffs = times.diff().dropna() + + if diffs.empty: + return [df] + + median_gap = diffs.median() + + # Auto-detect splits based on time gaps + auto_splits: set[int] = set() + if median_gap != timedelta(0): + threshold = outlier_factor * median_gap + for idx, gap in diffs.items(): + if gap > threshold: + pos = df.index.get_loc(idx) + auto_splits.add(pos) + + # Merge with manual splits (filter to valid range) + extra = set(manual_splits) if manual_splits else set() + return _build_groups(sorted(auto_splits | extra)) diff --git a/apps/tools/analyzer/parser.py b/apps/tools/analyzer/parser.py new file mode 100644 index 0000000..681286e --- /dev/null +++ b/apps/tools/analyzer/parser.py @@ -0,0 +1,107 @@ +import csv +import io +import pandas as pd +from datetime import datetime, timedelta + +CANONICAL_COLS = ["idx", "speed", "std_dev", "energy", "power_factor", "time"] +TIME_FORMATS = ["%H:%M:%S.%f", "%H:%M:%S", "%H:%M:%S,%f"] + + +def parse_csv(stream) -> pd.DataFrame: + raw = stream.read() + if isinstance(raw, bytes): + raw = raw.decode("utf-8-sig") + # Strip BOM characters that may appear anywhere in the file + raw = raw.replace("\ufeff", "") + + data_rows = [] + for line in raw.splitlines(): + fields = _split_line(line) + if len(fields) >= 6 and _is_index(fields[0]) and _is_time(fields[5]): + data_rows.append(fields[:6]) + + if len(data_rows) < 2: + raise ValueError( + "Could not find valid data rows in the CSV. " + "Expected rows with: integer index, 4 numeric values, and a time (HH:MM:SS)." + ) + + df = pd.DataFrame(data_rows, columns=CANONICAL_COLS) + + for col in ("speed", "std_dev", "energy", "power_factor"): + df[col] = _parse_numeric(df[col]) + + df["time"] = _parse_time_column(df["time"]) + df = df.sort_values("time").reset_index(drop=True) + return df[["speed", "std_dev", "energy", "power_factor", "time"]] + + +def _split_line(line: str) -> list: + """Parse one CSV line, respecting quoted fields.""" + for row in csv.reader([line], quotechar='"', doublequote=True, skipinitialspace=True): + return [f.strip() for f in row] + return [] + + +def _is_index(val: str) -> bool: + """True if the value is a non-negative integer (auto-increment row index).""" + try: + return int(val.strip()) >= 0 + except (ValueError, AttributeError): + return False + + +def _is_time(val: str) -> bool: + """True if the value parses as HH:MM:SS or HH:MM:SS.fff.""" + cleaned = val.strip() + for fmt in TIME_FORMATS: + try: + datetime.strptime(cleaned, fmt) + return True + except ValueError: + continue + return False + + +def _parse_numeric(col: pd.Series) -> pd.Series: + """Parse a numeric column, accepting both '.' and ',' as decimal separator.""" + result = pd.to_numeric(col, errors="coerce") + if result.isna().any(): + result = pd.to_numeric( + col.astype(str).str.replace(",", ".", regex=False), + errors="coerce", + ) + if result.isna().any(): + bad = col[result.isna()].tolist() + raise ValueError(f"Non-numeric values in column: {bad}") + return result + + +def _parse_time_column(col: pd.Series) -> pd.Series: + today = datetime.today().date() + cleaned = col.astype(str).str.strip() + + parsed = None + for fmt in TIME_FORMATS: + candidate = pd.to_datetime(cleaned, format=fmt, errors="coerce") + if candidate.notna().all(): + parsed = candidate + break + + if parsed is None: + candidate = pd.to_datetime(cleaned, errors="coerce") + if candidate.notna().all(): + parsed = candidate + + if parsed is None: + raise ValueError( + "Could not parse time column. Expected format: HH:MM:SS or HH:MM:SS.fff" + ) + + parsed = parsed.apply(lambda t: datetime.combine(today, t.time())) + + times = parsed.tolist() + for i in range(1, len(times)): + if times[i] < times[i - 1]: + times[i] += timedelta(days=1) + return pd.Series(times, index=col.index) diff --git a/apps/tools/analyzer/pdf_report.py b/apps/tools/analyzer/pdf_report.py new file mode 100644 index 0000000..8d5f548 --- /dev/null +++ b/apps/tools/analyzer/pdf_report.py @@ -0,0 +1,95 @@ +import base64 +import io +from datetime import datetime + +from fpdf import FPDF + +_COL_LABEL = 80 +_COL_VALUE = 50 +_ROW_H = 7 + + +def generate_pdf(overall: dict, group_stats: list, charts: list, overview_chart: str) -> bytes: + pdf = FPDF() + pdf.set_auto_page_break(auto=True, margin=15) + + pdf.add_page() + _title_block(pdf) + _overall_section(pdf, overall, overview_chart) + + for stat, chart_b64 in zip(group_stats, charts): + _group_section(pdf, stat, chart_b64) + + return bytes(pdf.output()) + + +# --------------------------------------------------------------------------- + +def _title_block(pdf: FPDF): + pdf.set_font("Helvetica", "B", 18) + pdf.cell(0, 12, "Ballistic Analysis Report", new_x="LMARGIN", new_y="NEXT", align="C") + pdf.set_font("Helvetica", "", 9) + pdf.cell( + 0, 5, + f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", + new_x="LMARGIN", new_y="NEXT", align="C", + ) + pdf.ln(8) + + +def _overall_section(pdf: FPDF, overall: dict, overview_chart: str): + _section_heading(pdf, "Overall Statistics") + rows = [ + ("Total shots", str(overall["count"])), + ("Min speed", f"{overall['min_speed']:.4f}"), + ("Max speed", f"{overall['max_speed']:.4f}"), + ("Mean speed", f"{overall['mean_speed']:.4f}"), + ("Std dev (speed)", f"{overall['std_speed']:.4f}" if overall["std_speed"] is not None else "n/a"), + ] + _table(pdf, rows) + img_bytes = base64.b64decode(overview_chart) + pdf.image(io.BytesIO(img_bytes), x=pdf.l_margin, w=min(140, pdf.epw)) + pdf.ln(4) + + +def _group_section(pdf: FPDF, stat: dict, chart_b64: str): + pdf.ln(4) + heading = ( + f"Group {stat['group_index']} - " + f"{stat['time_start']} to {stat['time_end']} " + f"({stat['count']} shot(s))" + ) + _section_heading(pdf, heading) + + rows = [ + ("Min speed", f"{stat['min_speed']:.4f}"), + ("Max speed", f"{stat['max_speed']:.4f}"), + ("Mean speed", f"{stat['mean_speed']:.4f}"), + ("Std dev (speed)", f"{stat['std_speed']:.4f}" if stat["std_speed"] is not None else "n/a"), + ] + _table(pdf, rows) + + img_bytes = base64.b64decode(chart_b64) + # Check remaining page space; add new page if chart won't fit + if pdf.get_y() + 75 > pdf.page_break_trigger: + pdf.add_page() + pdf.image(io.BytesIO(img_bytes), x=pdf.l_margin, w=pdf.epw) + pdf.ln(4) + + +def _section_heading(pdf: FPDF, text: str): + pdf.set_font("Helvetica", "B", 12) + pdf.set_fill_color(230, 236, 255) + pdf.cell(0, 8, text, new_x="LMARGIN", new_y="NEXT", fill=True) + pdf.ln(2) + + +def _table(pdf: FPDF, rows: list): + for i, (label, value) in enumerate(rows): + fill = i % 2 == 0 + pdf.set_fill_color(248, 249, 252) if fill else pdf.set_fill_color(255, 255, 255) + pdf.set_font("Helvetica", "", 10) + pdf.cell(_COL_LABEL, _ROW_H, label, border=0, fill=fill) + pdf.set_font("Helvetica", "B", 10) + pdf.cell(_COL_VALUE, _ROW_H, value, border=0, fill=fill, new_x="LMARGIN", new_y="NEXT") + pdf.ln(3) diff --git a/apps/tools/analyzer/stats.py b/apps/tools/analyzer/stats.py new file mode 100644 index 0000000..dd218d4 --- /dev/null +++ b/apps/tools/analyzer/stats.py @@ -0,0 +1,30 @@ +import pandas as pd + + +def compute_overall_stats(df: pd.DataFrame) -> dict: + s = df["speed"] + return { + "min_speed": s.min(), + "max_speed": s.max(), + "mean_speed": s.mean(), + "std_speed": s.std(ddof=1), + "count": len(df), + } + + +def compute_group_stats(groups: list) -> list: + result = [] + for i, g in enumerate(groups): + s = g["speed"] + std = s.std(ddof=1) if len(g) > 1 else None + result.append({ + "group_index": i + 1, + "count": len(g), + "min_speed": s.min(), + "max_speed": s.max(), + "mean_speed": s.mean(), + "std_speed": std, + "time_start": g["time"].min().strftime("%H:%M:%S"), + "time_end": g["time"].max().strftime("%H:%M:%S"), + }) + return result diff --git a/apps/tools/apps.py b/apps/tools/apps.py new file mode 100644 index 0000000..a6e23d6 --- /dev/null +++ b/apps/tools/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ToolsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.tools' diff --git a/apps/tools/migrations/0001_initial.py b/apps/tools/migrations/0001_initial.py new file mode 100644 index 0000000..f926bf7 --- /dev/null +++ b/apps/tools/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.16 on 2026-03-24 12:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ChronographAnalysis', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150, verbose_name='name')), + ('date', models.DateField(verbose_name='date')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='chronograph_analyses', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'chronograph analysis', + 'verbose_name_plural': 'chronograph analyses', + 'ordering': ['-date', '-created_at'], + }, + ), + migrations.CreateModel( + name='ResultPicture', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='result_pictures/', verbose_name='image')), + ('description', models.CharField(blank=True, max_length=255, verbose_name='description')), + ('group_size_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='group size (mm)')), + ('group_size_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='group size (MOA)')), + ('elevation_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='elevation offset (mm)')), + ('elevation_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='elevation offset (MOA)')), + ('windage_offset_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='windage offset (mm)')), + ('windage_offset_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='windage offset (MOA)')), + ('mean_radius_mm', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='mean radius (mm)')), + ('mean_radius_moa', models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True, verbose_name='mean radius (MOA)')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='uploaded at')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='result_pictures', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'result picture', + 'verbose_name_plural': 'result pictures', + 'ordering': ['-uploaded_at'], + }, + ), + migrations.CreateModel( + name='ShotGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=100, verbose_name='label')), + ('distance_m', models.DecimalField(blank=True, decimal_places=1, max_digits=7, null=True, verbose_name='distance (m)')), + ('order', models.PositiveSmallIntegerField(default=0, verbose_name='order')), + ('notes', models.TextField(blank=True, verbose_name='notes')), + ('analysis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shot_groups', to='tools.chronographanalysis', verbose_name='analysis')), + ('result_picture', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_group', to='tools.resultpicture', verbose_name='result picture')), + ], + options={ + 'verbose_name': 'shot group', + 'verbose_name_plural': 'shot groups', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='Shot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shot_number', models.PositiveSmallIntegerField(editable=False, verbose_name='shot number')), + ('velocity_fps', models.DecimalField(decimal_places=1, max_digits=6, verbose_name='velocity (fps)')), + ('notes', models.CharField(blank=True, max_length=255, verbose_name='notes')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shots', to='tools.shotgroup', verbose_name='group')), + ], + options={ + 'verbose_name': 'shot', + 'verbose_name_plural': 'shots', + 'ordering': ['shot_number'], + }, + ), + migrations.AddConstraint( + model_name='shot', + constraint=models.UniqueConstraint(fields=('group', 'shot_number'), name='unique_shot_number_per_group'), + ), + ] diff --git a/apps/tools/migrations/0002_shotgroup_ammo_batch.py b/apps/tools/migrations/0002_shotgroup_ammo_batch.py new file mode 100644 index 0000000..4591fd6 --- /dev/null +++ b/apps/tools/migrations/0002_shotgroup_ammo_batch.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2026-03-24 13:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gears', '0003_ammo_brass_bullet_powder_primer_alter_bipod_options_and_more'), + ('tools', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='shotgroup', + name='ammo_batch', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to='gears.reloadedammobatch', verbose_name='reloaded ammo batch'), + ), + ] diff --git a/apps/tools/migrations/0003_remove_shotgroup_result_picture_delete_resultpicture.py b/apps/tools/migrations/0003_remove_shotgroup_result_picture_delete_resultpicture.py new file mode 100644 index 0000000..18352a2 --- /dev/null +++ b/apps/tools/migrations/0003_remove_shotgroup_result_picture_delete_resultpicture.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2026-03-25 10:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tools', '0002_shotgroup_ammo_batch'), + ] + + operations = [ + migrations.RemoveField( + model_name='shotgroup', + name='result_picture', + ), + migrations.DeleteModel( + name='ResultPicture', + ), + ] diff --git a/apps/tools/migrations/0004_shotgroup_nullable_analysis_user_ammo.py b/apps/tools/migrations/0004_shotgroup_nullable_analysis_user_ammo.py new file mode 100644 index 0000000..fa13a75 --- /dev/null +++ b/apps/tools/migrations/0004_shotgroup_nullable_analysis_user_ammo.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.16 on 2026-03-30 13:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gears', '0011_rig_ballistic_fields'), + ('tools', '0003_remove_shotgroup_result_picture_delete_resultpicture'), + ] + + operations = [ + migrations.AddField( + model_name='shotgroup', + name='ammo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to='gears.ammo', verbose_name='factory ammo'), + ), + migrations.AddField( + model_name='shotgroup', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to=settings.AUTH_USER_MODEL, verbose_name='user'), + ), + migrations.AlterField( + model_name='shotgroup', + name='analysis', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='shot_groups', to='tools.chronographanalysis', verbose_name='analysis'), + ), + ] diff --git a/apps/tools/migrations/0005_chronographanalysis_is_public.py b/apps/tools/migrations/0005_chronographanalysis_is_public.py new file mode 100644 index 0000000..78006f0 --- /dev/null +++ b/apps/tools/migrations/0005_chronographanalysis_is_public.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tools', '0004_shotgroup_nullable_analysis_user_ammo'), + ] + + operations = [ + migrations.AddField( + model_name='chronographanalysis', + name='is_public', + field=models.BooleanField(default=False, verbose_name='public'), + ), + ] diff --git a/apps/tools/migrations/__init__.py b/apps/tools/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tools/models.py b/apps/tools/models.py new file mode 100644 index 0000000..e5c60a9 --- /dev/null +++ b/apps/tools/models.py @@ -0,0 +1,157 @@ +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) diff --git a/apps/tools/permissions.py b/apps/tools/permissions.py new file mode 100644 index 0000000..3ba0c90 --- /dev/null +++ b/apps/tools/permissions.py @@ -0,0 +1,27 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsOwnerOrUnclaimed(BasePermission): + """ + Permission for resources with an optional `user` FK. + + - POST (create): open to anyone — viewset sets user=None for anonymous callers. + - GET list: viewset filters to own records (or returns empty for anonymous). + - GET detail: open to anyone with the ID. + - PATCH/PUT/DELETE: + * unclaimed (user=None) → anyone may mutate. + * claimed (user set) → owner only. + + NOTE: The global DRF default is IsAuthenticated; this class must be + explicitly declared on every viewset in the tools app. + """ + + def has_permission(self, request, view): + return True # object-level and queryset filtering handle the rest + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + if obj.user is None: + return True + return request.user.is_authenticated and obj.user == request.user diff --git a/apps/tools/serializers.py b/apps/tools/serializers.py new file mode 100644 index 0000000..f5035bf --- /dev/null +++ b/apps/tools/serializers.py @@ -0,0 +1,176 @@ +import math + +from rest_framework import serializers + +from apps.common.serializer_helpers import ammo_detail, batch_detail +# Intentional cross-app import: tools depends on gears for ammo linking. +from apps.gears.models import Ammo, GearStatus, ReloadedAmmoBatch + +from .models import ChronographAnalysis, Shot, ShotGroup + + +# ── Stats helper ────────────────────────────────────────────────────────────── + +def _compute_stats(shot_qs): + """ + Compute velocity statistics from a Shot queryset. + + Uses population standard deviation (divide by N) — consistent with how + chronograph software typically reports SD for a complete string of fire. + Returns None for all values when there are no shots. + """ + fps_values = [float(s.velocity_fps) for s in shot_qs] + count = len(fps_values) + if count == 0: + return { + 'count': 0, + 'avg_fps': None, 'avg_mps': None, + 'sd_fps': None, + 'es_fps': None, + 'min_fps': None, 'max_fps': None, + } + avg = sum(fps_values) / count + min_fps = min(fps_values) + max_fps = max(fps_values) + variance = sum((v - avg) ** 2 for v in fps_values) / count + return { + 'count': count, + 'avg_fps': round(avg, 1), + 'avg_mps': round(avg * 0.3048, 1), + 'sd_fps': round(math.sqrt(variance), 1), + 'es_fps': round(max_fps - min_fps, 1), + 'min_fps': round(min_fps, 1), + 'max_fps': round(max_fps, 1), + } + + +# ── Shot ────────────────────────────────────────────────────────────────────── + +class ShotSerializer(serializers.ModelSerializer): + class Meta: + model = Shot + fields = ['id', 'shot_number', 'velocity_fps', 'notes'] + read_only_fields = ['shot_number'] + + def validate(self, attrs): + instance = Shot(**attrs) + instance.clean() + return attrs + + +# ── ShotGroup (nested under analysis) ──────────────────────────────────────── + +class ShotGroupSerializer(serializers.ModelSerializer): + # Write: accept a ReloadedAmmoBatch PK (nullable) + ammo_batch = serializers.PrimaryKeyRelatedField( + queryset=ReloadedAmmoBatch.objects.all(), + required=False, allow_null=True, + ) + ammo = serializers.PrimaryKeyRelatedField( + queryset=Ammo.objects.filter(status=GearStatus.VERIFIED), + required=False, allow_null=True, + ) + # Read: compact inline summaries + ammo_batch_detail = serializers.SerializerMethodField() + ammo_detail = serializers.SerializerMethodField() + shots = ShotSerializer(many=True, read_only=True) + stats = serializers.SerializerMethodField() + + class Meta: + model = ShotGroup + fields = [ + 'id', 'label', 'distance_m', 'order', 'notes', + 'ammo_batch', # write / read (PK) + 'ammo_batch_detail', # read (inline) + 'ammo', # write / read (PK) + 'ammo_detail', # read (inline) + 'shots', + 'stats', + ] + + def get_stats(self, obj): + return _compute_stats(obj.shots.all()) + + def get_ammo_batch_detail(self, obj): + if not obj.ammo_batch_id: + return None + return batch_detail(obj.ammo_batch) + + def get_ammo_detail(self, obj): + if not obj.ammo_id: + return None + return ammo_detail(obj.ammo) + + def validate(self, attrs): + check_attrs = {k: v for k, v in attrs.items() if k not in ('ammo_batch', 'ammo')} + instance = ShotGroup(**check_attrs) + instance.clean() + return attrs + + def create(self, validated_data): + analysis = self.context.get('analysis') + if analysis is not None: + validated_data['analysis'] = analysis + return ShotGroup.objects.create(**validated_data) + + +# ── ShotGroup (standalone, top-level /api/groups/) ─────────────────────────── + +class ShotGroupStandaloneSerializer(ShotGroupSerializer): + """ + Extends ShotGroupSerializer with writable `analysis` field for + standalone groups or groups linked to an analysis after creation. + """ + analysis = serializers.PrimaryKeyRelatedField( + queryset=ChronographAnalysis.objects.none(), # narrowed in __init__ + required=False, allow_null=True, + ) + ammo_batch = serializers.PrimaryKeyRelatedField( + queryset=ReloadedAmmoBatch.objects.none(), # narrowed in __init__ + required=False, allow_null=True, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get('request') + if request and request.user.is_authenticated: + self.fields['analysis'].queryset = ChronographAnalysis.objects.filter( + user=request.user + ) + self.fields['ammo_batch'].queryset = ReloadedAmmoBatch.objects.filter( + recipe__user=request.user + ) + + class Meta(ShotGroupSerializer.Meta): + fields = ShotGroupSerializer.Meta.fields + ['analysis'] + + def create(self, validated_data): + # user is injected by perform_create + return ShotGroup.objects.create(**validated_data) + + +# ── ChronographAnalysis ─────────────────────────────────────────────────────── + +class ChronographAnalysisListSerializer(serializers.ModelSerializer): + class Meta: + model = ChronographAnalysis + fields = ['id', 'user', 'name', 'date', 'is_public', 'created_at'] + read_only_fields = ['user', 'created_at'] + + +class ChronographAnalysisDetailSerializer(serializers.ModelSerializer): + shot_groups = ShotGroupSerializer(many=True, read_only=True) + + class Meta: + model = ChronographAnalysis + fields = [ + 'id', 'user', 'name', 'date', 'notes', 'is_public', + 'shot_groups', + 'created_at', 'updated_at', + ] + read_only_fields = ['user', 'created_at', 'updated_at'] + + def validate(self, attrs): + instance = ChronographAnalysis(**attrs) + instance.clean() + return attrs diff --git a/apps/tools/urls.py b/apps/tools/urls.py new file mode 100644 index 0000000..fbafb52 --- /dev/null +++ b/apps/tools/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ChronographAnalysisViewSet, ShotGroupViewSet + +router = DefaultRouter() +router.register(r'tools/chronograph', ChronographAnalysisViewSet, basename='chronograph') +router.register(r'groups', ShotGroupViewSet, basename='shot-group') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/tools/views.py b/apps/tools/views.py new file mode 100644 index 0000000..6825ec6 --- /dev/null +++ b/apps/tools/views.py @@ -0,0 +1,319 @@ +import datetime + +import pandas as pd +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from rest_framework import parsers, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from .analyzer.charts import render_group_charts, render_overview_chart +from .analyzer.grouper import detect_groups +from .analyzer.parser import parse_csv +from .analyzer.pdf_report import generate_pdf +from .analyzer.stats import compute_group_stats, compute_overall_stats +from .models import ChronographAnalysis, Shot, ShotGroup +from .permissions import IsOwnerOrUnclaimed +from .serializers import ( + ChronographAnalysisDetailSerializer, + ChronographAnalysisListSerializer, + ShotGroupSerializer, + ShotGroupStandaloneSerializer, + ShotSerializer, +) + + +# ── Standalone ShotGroup ────────────────────────────────────────────────────── + +class ShotGroupViewSet(viewsets.ModelViewSet): + """ + Top-level CRUD for ShotGroups that may or may not belong to an analysis. + + GET/POST /api/groups/ + GET/PATCH/DELETE /api/groups/{id}/ + """ + permission_classes = [IsOwnerOrUnclaimed] + serializer_class = ShotGroupStandaloneSerializer + + def get_queryset(self): + from django.db.models import Q + user = self.request.user + if not user.is_authenticated: + return ShotGroup.objects.none() + return ( + ShotGroup.objects + .filter( + Q(analysis__user=user) | + Q(analysis__isnull=True, user=user) + ) + .select_related('analysis', 'ammo_batch__recipe', 'ammo_batch__powder', 'ammo') + .prefetch_related('shots') + .distinct() + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +def _group_to_df(group): + """Convert a ShotGroup ORM object to a DataFrame expected by the analyzer.""" + shots = list(group.shots.order_by('shot_number')) + if not shots: + return pd.DataFrame(columns=['speed', 'time']) + rows = [] + base = datetime.datetime(2000, 1, 1, 0, 0, 0) + for i, shot in enumerate(shots): + rows.append({ + 'speed': float(shot.velocity_fps), + 'time': base + datetime.timedelta(seconds=i), + }) + return pd.DataFrame(rows) + + +# ── ChronographAnalysis ─────────────────────────────────────────────────────── + +class ChronographAnalysisViewSet(viewsets.ModelViewSet): + """ + Manage chronograph analysis sessions and their nested shot groups / shots. + + Standard CRUD on the analysis itself, plus nested actions: + GET/POST .../groups/ list / create shot groups + GET/PATCH/DELETE .../groups/{group_pk}/ manage a single group + GET/POST .../groups/{group_pk}/shots/ list / add individual shots + DELETE .../groups/{group_pk}/shots/{shot_pk}/ remove a single shot + """ + permission_classes = [IsOwnerOrUnclaimed] + pagination_class = None # sidebar needs the full list; pagination handled client-side + + def get_queryset(self): + return ( + ChronographAnalysis.objects + .prefetch_related('shot_groups__shots') + ) + + def get_serializer_class(self): + if self.action == 'list': + return ChronographAnalysisListSerializer + return ChronographAnalysisDetailSerializer + + def perform_create(self, serializer): + user = self.request.user if self.request.user.is_authenticated else None + serializer.save(user=user) + + # ── Shot groups ─────────────────────────────────────────────────────────── + + @action(detail=True, methods=['get', 'post'], url_path='groups') + def groups(self, request, pk=None): + analysis = self.get_object() + + if request.method == 'GET': + qs = analysis.shot_groups.prefetch_related('shots').order_by('order', 'id') + serializer = ShotGroupSerializer(qs, many=True, context={'request': request}) + return Response(serializer.data) + + serializer = ShotGroupSerializer( + data=request.data, + context={'request': request, 'analysis': analysis}, + ) + serializer.is_valid(raise_exception=True) + group = serializer.save() + return Response( + ShotGroupSerializer(group, context={'request': request}).data, + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=['get', 'patch', 'delete'], + url_path=r'groups/(?P[^/.]+)', + ) + def group_detail(self, request, pk=None, group_pk=None): + analysis = self.get_object() + group = get_object_or_404(ShotGroup, pk=group_pk, analysis=analysis) + + if request.method == 'DELETE': + group.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + if request.method == 'GET': + serializer = ShotGroupSerializer(group, context={'request': request}) + return Response(serializer.data) + + # PATCH + serializer = ShotGroupSerializer( + group, + data=request.data, + partial=True, + context={'request': request, 'analysis': analysis}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + # ── Shots ───────────────────────────────────────────────────────────────── + + @action( + detail=True, + methods=['get', 'post'], + url_path=r'groups/(?P[^/.]+)/shots', + ) + def shots(self, request, pk=None, group_pk=None): + analysis = self.get_object() + group = get_object_or_404(ShotGroup, pk=group_pk, analysis=analysis) + + if request.method == 'GET': + serializer = ShotSerializer(group.shots.all(), many=True, context={'request': request}) + return Response(serializer.data) + + serializer = ShotSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + shot = serializer.save(group=group) + return Response( + ShotSerializer(shot, context={'request': request}).data, + status=status.HTTP_201_CREATED, + ) + + @action( + detail=True, + methods=['delete'], + url_path=r'groups/(?P[^/.]+)/shots/(?P[^/.]+)', + ) + def shot_detail(self, request, pk=None, group_pk=None, shot_pk=None): + analysis = self.get_object() + group = get_object_or_404(ShotGroup, pk=group_pk, analysis=analysis) + shot = get_object_or_404(Shot, pk=shot_pk, group=group) + shot.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + # ── CSV upload ───────────────────────────────────────────────────────────── + + @action( + detail=False, + methods=['post'], + url_path='upload', + parser_classes=[parsers.MultiPartParser], + permission_classes=[IsOwnerOrUnclaimed], + ) + def upload(self, request): + """ + Parse a CSV file and create a ChronographAnalysis with auto-detected groups. + Expected CSV columns: idx, speed, std_dev, energy, power_factor, time (HH:MM:SS) + """ + file = request.FILES.get('file') + if not file: + return Response({'detail': 'No file provided.'}, status=status.HTTP_400_BAD_REQUEST) + + name = request.data.get('name', '').strip() or file.name.rsplit('.', 1)[0] + notes = request.data.get('notes', '') + date_str = request.data.get('date', '') + velocity_unit = request.data.get('velocity_unit', 'fps') # 'fps' or 'mps' + chrono_type = request.data.get('chrono_type', 'garmin_xero_c1_pro') # future: switch parser + + SUPPORTED_CHRONO_TYPES = {'garmin_xero_c1_pro'} + if chrono_type not in SUPPORTED_CHRONO_TYPES: + return Response( + {'detail': f'Unsupported chronograph type: {chrono_type}'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + df = parse_csv(file) + except ValueError as exc: + return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + groups = detect_groups(df) + + # Determine session date + if date_str: + try: + session_date = datetime.date.fromisoformat(date_str) + except ValueError: + session_date = datetime.date.today() + else: + try: + session_date = df['time'].iloc[0].date() + except Exception: + session_date = datetime.date.today() + + analysis = ChronographAnalysis.objects.create( + user=request.user if request.user.is_authenticated else None, + name=name, + date=session_date, + notes=notes, + ) + + for i, gdf in enumerate(groups): + group = ShotGroup.objects.create( + analysis=analysis, + label=f'Group {i + 1}', + order=i, + ) + for _, row in gdf.iterrows(): + speed = float(row['speed']) + if velocity_unit == 'mps': + speed = speed / 0.3048 # convert m/s → fps + Shot.objects.create(group=group, velocity_fps=speed) + + return Response( + ChronographAnalysisListSerializer(analysis).data, + status=status.HTTP_201_CREATED, + ) + + # ── Charts ───────────────────────────────────────────────────────────────── + + @action(detail=True, methods=['get'], url_path='charts') + def charts(self, request, pk=None): + """Return base64-encoded PNG charts for an analysis.""" + analysis = self.get_object() + groups_qs = analysis.shot_groups.prefetch_related('shots').order_by('order', 'id') + + groups_dfs = [_group_to_df(g) for g in groups_qs] + groups_dfs = [gdf for gdf in groups_dfs if not gdf.empty] + + if not groups_dfs: + return Response({'overview': None, 'groups': []}) + + group_stats = compute_group_stats(groups_dfs) + + all_speeds = [s for gs in group_stats for s in [gs.get('min_speed'), gs.get('max_speed')] if s is not None] + y_min = min(all_speeds) if all_speeds else 0 + y_max = max(all_speeds) if all_speeds else 1000 + + return Response({ + 'overview': render_overview_chart(group_stats), + 'groups': render_group_charts(groups_dfs, y_min, y_max), + }) + + # ── PDF report ───────────────────────────────────────────────────────────── + + @action(detail=True, methods=['get'], url_path='report.pdf') + def report_pdf(self, request, pk=None): + """Generate and return a PDF analysis report.""" + analysis = self.get_object() + groups_qs = analysis.shot_groups.prefetch_related('shots').order_by('order', 'id') + + groups_dfs = [_group_to_df(g) for g in groups_qs] + groups_dfs = [gdf for gdf in groups_dfs if not gdf.empty] + + if not groups_dfs: + return Response({'detail': 'No shot data available.'}, status=status.HTTP_404_NOT_FOUND) + + all_df = pd.concat(groups_dfs, ignore_index=True) + overall_stats = compute_overall_stats(all_df) + group_stats = compute_group_stats(groups_dfs) + + all_speeds = [s for gs in group_stats for s in [gs.get('min_speed'), gs.get('max_speed')] if s is not None] + y_min = min(all_speeds) if all_speeds else 0 + y_max = max(all_speeds) if all_speeds else 1000 + + group_charts = render_group_charts(groups_dfs, y_min, y_max) + overview_chart = render_overview_chart(group_stats) + + pdf_bytes = generate_pdf(overall_stats, group_stats, group_charts, overview_chart) + + safe_name = analysis.name.replace(' ', '_').replace('/', '-') + return HttpResponse( + pdf_bytes, + content_type='application/pdf', + headers={'Content-Disposition': f'attachment; filename="{safe_name}.pdf"'}, + ) diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..67e4ee7 --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from .models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + list_display = ('email', 'username', 'is_staff', 'is_active') + ordering = ('email',) diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..2bb189c --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' diff --git a/apps/users/management/__init__.py b/apps/users/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/management/commands/__init__.py b/apps/users/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/management/commands/create_default_admin.py b/apps/users/management/commands/create_default_admin.py new file mode 100644 index 0000000..47d0b03 --- /dev/null +++ b/apps/users/management/commands/create_default_admin.py @@ -0,0 +1,21 @@ +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Create a default superuser for development (idempotent).' + + def handle(self, *args, **options): + User = get_user_model() + email = 'admin@shooterhub.local' + if User.objects.filter(email=email).exists(): + self.stdout.write(f'Admin already exists: {email}') + return + User.objects.create_superuser( + username='admin', + email=email, + password='changeme', + ) + self.stdout.write(self.style.SUCCESS( + f'Superuser created — email: {email} password: changeme' + )) diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..1f7b0f6 --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.16 on 2026-03-24 09:48 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/apps/users/migrations/0002_user_avatar.py b/apps/users/migrations/0002_user_avatar.py new file mode 100644 index 0000000..5ee9483 --- /dev/null +++ b/apps/users/migrations/0002_user_avatar.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='avatar', + field=models.ImageField(blank=True, null=True, upload_to='avatars/'), + ), + ] diff --git a/apps/users/migrations/0003_user_language.py b/apps/users/migrations/0003_user_language.py new file mode 100644 index 0000000..73ae91d --- /dev/null +++ b/apps/users/migrations/0003_user_language.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_user_avatar'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='language', + field=models.CharField( + choices=[('en', 'English'), ('fr', 'Français'), ('de', 'Deutsch'), ('es', 'Español')], + default='en', + max_length=5, + verbose_name='language', + ), + ), + ] diff --git a/apps/users/migrations/0004_alter_user_avatar.py b/apps/users/migrations/0004_alter_user_avatar.py new file mode 100644 index 0000000..a211be5 --- /dev/null +++ b/apps/users/migrations/0004_alter_user_avatar.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2026-03-25 10:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('photos', '0001_initial'), + ('users', '0003_user_language'), + ] + + operations = [ + # Clear empty-string avatar values left by the old ImageField before + # converting the column to a bigint FK. + migrations.RunSQL( + sql="UPDATE users_user SET avatar = NULL WHERE avatar = ''", + reverse_sql=migrations.RunSQL.noop, + ), + migrations.AlterField( + model_name='user', + name='avatar', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='avatar_user', to='photos.photo'), + ), + ] diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..df5fc75 --- /dev/null +++ b/apps/users/models.py @@ -0,0 +1,31 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + + +LANGUAGE_CHOICES = [ + ('en', 'English'), + ('fr', 'Français'), + ('de', 'Deutsch'), + ('es', 'Español'), +] + + +class User(AbstractUser): + """ + Custom user model — kept intentionally thin so the schema can evolve. + Authentication is handled by dj-rest-auth + allauth (JWT + external IDP). + """ + email = models.EmailField(unique=True) + avatar = models.ForeignKey( + 'photos.Photo', + null=True, blank=True, + on_delete=models.SET_NULL, + related_name='avatar_user', + ) + language = models.CharField(max_length=5, choices=LANGUAGE_CHOICES, default='en', verbose_name='language') + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username'] + + def __str__(self): + return self.email diff --git a/apps/users/serializers.py b/apps/users/serializers.py new file mode 100644 index 0000000..0f40fb9 --- /dev/null +++ b/apps/users/serializers.py @@ -0,0 +1,52 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +# Intentional cross-app import: users depends on photos for DB-backed avatar. +from apps.photos.models import Photo + +User = get_user_model() + + +class UserProfileSerializer(serializers.ModelSerializer): + # Write: accept a Photo PK; Read: return the serve URL + avatar = serializers.PrimaryKeyRelatedField( + required=False, + allow_null=True, + queryset=Photo.objects.all(), + ) + avatar_url = serializers.SerializerMethodField() + is_staff = serializers.BooleanField(read_only=True) + + class Meta: + model = User + fields = ['id', 'email', 'username', 'first_name', 'last_name', + 'avatar', 'avatar_url', 'is_staff', 'language'] + read_only_fields = ['id', 'email', 'username', 'is_staff'] + + def get_avatar_url(self, obj) -> str | None: + if not obj.avatar_id: + return None + return f'/api/photos/{obj.avatar_id}/data/' + + +class AdminUserSerializer(serializers.ModelSerializer): + """Flat serializer for the admin user-management panel.""" + + class Meta: + model = User + fields = ['id', 'email', 'username', 'first_name', 'last_name', + 'is_active', 'is_staff', 'date_joined'] + read_only_fields = ['id', 'email', 'username', 'date_joined'] + + +class AdminCreateUserSerializer(serializers.ModelSerializer): + """Serializer used by admins to create a new user account.""" + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ['username', 'email', 'password', 'first_name', 'last_name', 'is_staff'] + + def create(self, validated_data): + password = validated_data.pop('password') + return User.objects.create_user(password=password, **validated_data) diff --git a/apps/users/urls.py b/apps/users/urls.py new file mode 100644 index 0000000..309ad22 --- /dev/null +++ b/apps/users/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import AdminUserDetailView, AdminUserListView, UserProfileView + +urlpatterns = [ + path('profile/', UserProfileView.as_view(), name='user-profile'), + path('admin/', AdminUserListView.as_view(), name='admin-user-list'), + path('admin//', AdminUserDetailView.as_view(), name='admin-user-detail'), +] diff --git a/apps/users/views.py b/apps/users/views.py new file mode 100644 index 0000000..ebbac69 --- /dev/null +++ b/apps/users/views.py @@ -0,0 +1,34 @@ +from rest_framework import generics, permissions, parsers + +from .serializers import AdminCreateUserSerializer, AdminUserSerializer, UserProfileSerializer +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserProfileView(generics.RetrieveUpdateAPIView): + """GET / PATCH the authenticated user's profile (including avatar upload).""" + serializer_class = UserProfileSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [parsers.MultiPartParser, parsers.FormParser, parsers.JSONParser] + + def get_object(self): + return self.request.user + + +class AdminUserListView(generics.ListCreateAPIView): + """Admin: list all users (GET) or create a new user (POST).""" + permission_classes = [permissions.IsAdminUser] + queryset = User.objects.all().order_by('date_joined') + + def get_serializer_class(self): + if self.request.method == 'POST': + return AdminCreateUserSerializer + return AdminUserSerializer + + +class AdminUserDetailView(generics.RetrieveUpdateAPIView): + """Admin: activate/deactivate or promote/demote a user.""" + serializer_class = AdminUserSerializer + permission_classes = [permissions.IsAdminUser] + queryset = User.objects.all() diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/pagination.py b/config/pagination.py new file mode 100644 index 0000000..e8bdffe --- /dev/null +++ b/config/pagination.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class StandardPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = 'page_size' + max_page_size = 1000 diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..fad5266 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,179 @@ +from datetime import timedelta +from pathlib import Path + +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env( + DEBUG=(bool, False), +) +environ.Env.read_env(BASE_DIR / '.env') + +SECRET_KEY = env('SECRET_KEY') +DEBUG = env('DEBUG') +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*']) + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + # Third-party + 'rest_framework', + 'rest_framework.authtoken', + 'rest_framework_simplejwt.token_blacklist', + 'django_filters', + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + 'allauth.socialaccount.providers.apple', + 'dj_rest_auth', + 'dj_rest_auth.registration', + # Local + 'apps.users', + 'apps.gears', + 'apps.tools', + 'apps.photos', + 'apps.sessions', + 'apps.calibers', + 'apps.social', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'allauth.account.middleware.AccountMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +DATABASES = { + 'default': env.db( + 'DATABASE_URL', + default='postgresql://shooter:shooter_secret@db:5432/shooter_hub', + ) +} + +AUTH_USER_MODEL = 'users.User' + +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +] + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +LANGUAGES = [ + ('en', 'English'), + ('fr', 'Français'), + ('de', 'Deutsch'), + ('es', 'Español'), +] + +LOCALE_PATHS = [BASE_DIR / 'locale'] + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# ── Django REST Framework ────────────────────────────────────────────────────── +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter', + ], + 'DEFAULT_PAGINATION_CLASS': 'config.pagination.StandardPagination', + 'PAGE_SIZE': 20, +} + +# ── SimpleJWT ───────────────────────────────────────────────────────────────── +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# ── dj-rest-auth ────────────────────────────────────────────────────────────── +REST_AUTH = { + 'USE_JWT': True, + 'JWT_AUTH_HTTPONLY': False, # expose refresh token to mobile clients +} + +# ── django-allauth ──────────────────────────────────────────────────────────── +SITE_ID = 1 +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_AUTHENTICATION_METHOD = 'email' +ACCOUNT_EMAIL_VERIFICATION = 'none' # change to 'mandatory' in production + +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'APP': { + 'client_id': env('GOOGLE_CLIENT_ID', default=''), + 'secret': env('GOOGLE_CLIENT_SECRET', default=''), + }, + 'SCOPE': ['profile', 'email'], + 'AUTH_PARAMS': {'access_type': 'online'}, + }, + 'apple': { + 'APP': { + 'client_id': env('APPLE_CLIENT_ID', default=''), + 'secret': env('APPLE_CLIENT_SECRET', default=''), + }, + }, +} + +# Development: print emails to console +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..366ca08 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,54 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from rest_framework_simplejwt.views import TokenRefreshView + +from allauth.socialaccount.providers.apple.views import AppleOAuth2Adapter +from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter +from allauth.socialaccount.providers.oauth2.client import OAuth2Client +from dj_rest_auth.registration.views import SocialLoginView + +from .views import public_feed + + +class GoogleLoginView(SocialLoginView): + adapter_class = GoogleOAuth2Adapter + client_class = OAuth2Client + + +class AppleLoginView(SocialLoginView): + adapter_class = AppleOAuth2Adapter + client_class = OAuth2Client + + +urlpatterns = [ + path('admin/', admin.site.urls), + # Auth + path('api/auth/', include('dj_rest_auth.urls')), + path('api/auth/registration/', include('dj_rest_auth.registration.urls')), + path('api/auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + # Social / external IDP + # Mobile flow: POST {"access_token": ""} → returns JWT pair + path('api/auth/social/google/', GoogleLoginView.as_view(), name='google_login'), + path('api/auth/social/apple/', AppleLoginView.as_view(), name='apple_login'), + # Users app + path('api/users/', include('apps.users.urls')), + # Gears app + path('api/', include('apps.gears.urls')), + # Tools app + path('api/', include('apps.tools.urls')), + # Photos app + path('api/photos/', include('apps.photos.urls')), + # Sessions app + path('api/', include('apps.sessions.urls')), + # Calibers app + path('api/', include('apps.calibers.urls')), + # Social app (messages, blog, bugs, friends) + path('api/', include('apps.social.urls')), + # Public feed + path('api/feed/', public_feed, name='public-feed'), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/views.py b/config/views.py new file mode 100644 index 0000000..3b24a34 --- /dev/null +++ b/config/views.py @@ -0,0 +1,98 @@ +""" +Public feed endpoint — returns the latest publicly shared items from each +content type. No authentication required. +""" +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.photos.models import GroupPhoto +from apps.sessions.models import FreePracticeSession, PRSSession, SpeedShootingSession +from apps.tools.models import ChronographAnalysis +from apps.gears.models import ReloadRecipe + + +def _session_row(s, kind): + label = getattr(s, 'competition_name', None) or s.name or '(unnamed)' + return { + 'id': s.id, + 'type': kind, + 'label': label, + 'date': str(s.date) if s.date else None, + 'location': s.location or None, + } + + +@api_view(['GET']) +@permission_classes([AllowAny]) +def public_feed(request): + limit = min(int(request.query_params.get('limit', 8)), 20) + + # ── Sessions (merge all types) ────────────────────────────────────────── + prs = list(PRSSession.objects.filter(is_public=True).order_by('-date')[:limit]) + fp = list(FreePracticeSession.objects.filter(is_public=True).order_by('-date')[:limit]) + ss = list(SpeedShootingSession.objects.filter(is_public=True).order_by('-date')[:limit]) + + sessions_raw = ( + [_session_row(s, 'PRS') for s in prs] + + [_session_row(s, 'Practice') for s in fp] + + [_session_row(s, 'Speed') for s in ss] + ) + sessions = sorted(sessions_raw, key=lambda x: x['date'] or '', reverse=True)[:limit] + + # ── Analyses ──────────────────────────────────────────────────────────── + analyses_qs = ( + ChronographAnalysis.objects + .filter(is_public=True) + .order_by('-date')[:limit] + ) + analyses = [ + { + 'id': a.id, + 'name': a.name, + 'date': str(a.date) if a.date else None, + } + for a in analyses_qs + ] + + # ── Photos ────────────────────────────────────────────────────────────── + photos_qs = ( + GroupPhoto.objects + .filter(is_public=True) + .select_related('photo', 'analysis') + .order_by('-photo__uploaded_at')[:limit] + ) + photos = [] + for gp in photos_qs: + an = getattr(gp, 'analysis', None) + photos.append({ + 'id': gp.id, + 'photo_id': gp.photo_id, + 'caption': gp.caption or None, + 'group_size_mm': str(an.group_size_mm) if an and an.group_size_mm is not None else None, + 'group_size_moa': str(an.group_size_moa) if an and an.group_size_moa is not None else None, + }) + + # ── Reload recipes ────────────────────────────────────────────────────── + recipes_qs = ( + ReloadRecipe.objects + .filter(is_public=True) + .select_related('caliber', 'bullet', 'primer', 'brass') + .order_by('-created_at')[:limit] + ) + recipes = [] + for r in recipes_qs: + recipes.append({ + 'id': r.id, + 'name': r.name, + 'caliber': r.caliber.name if r.caliber_id else None, + 'bullet': str(r.bullet) if r.bullet_id else None, + 'date': str(r.created_at.date()), + }) + + return Response({ + 'sessions': sessions, + 'analyses': analyses, + 'photos': photos, + 'recipes': recipes, + }) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..a9f185c --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a45451 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + db: + image: postgres:16-alpine + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${POSTGRES_DB:-shooter_hub} + POSTGRES_USER: ${POSTGRES_USER:-shooter} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shooter_secret} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-shooter} -d ${POSTGRES_DB:-shooter_hub}"] + interval: 5s + timeout: 5s + retries: 5 + + web: + build: . + command: > + sh -c "python manage.py migrate --noinput && + python manage.py compilemessages && + python manage.py create_default_admin && + python manage.py import_weapons_csv /app/weapon.csv && + python manage.py runserver 0.0.0.0:8000" + volumes: + - .:/app + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + env_file: + - .env + + frontend: + image: nginx:alpine + volumes: + - ./frontend:/usr/share/nginx/html:ro + - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "5173:80" + depends_on: + - web + +volumes: + postgres_data: diff --git a/docs/frontend.md b/docs/frontend.md new file mode 100644 index 0000000..3bbfba4 --- /dev/null +++ b/docs/frontend.md @@ -0,0 +1,293 @@ +# Frontend Architecture + +ShooterHub's frontend is **vanilla JS + Bootstrap 5.3**, served as-is by Nginx. There is no build step, no bundler, and no framework. + +--- + +## File Structure + +``` +frontend/ +├── index.html # Landing page / community feed +├── dashboard.html # User dashboard (stats summary) +├── sessions.html # Shooting sessions list + detail panel +├── chrono.html # Chronograph analysis +├── group-size.html # Target photo annotation tool +├── photos.html # Group photo gallery +├── reloads.html # Reloading recipes + batches +├── messages.html # Private messaging +├── friends.html # Friendships / follow requests +├── css/ +│ └── app.css # Global styles (minimal, Bootstrap-first) +└── js/ + ├── api.js # HTTP client (JWT, refresh, helpers) + ├── utils.js # Shared UI utilities + ├── i18n.js # Client-side translations + ├── nav.js # Navbar builder + auth gate + badge polling + ├── sessions.js # Sessions page + ├── chrono.js # Chrono page + ├── group-size.js # Annotation canvas tool + ├── photos.js # Photo gallery + lightbox + ├── reloads.js # Reloads page + ├── messages.js # Messaging page + └── friends.js # Friends page +``` + +--- + +## Script Load Order + +Every HTML page loads scripts in this fixed order: + +```html + + + + + +``` + +`nav.js` **blocks unauthenticated access** to protected routes. It checks for a JWT in `localStorage` and redirects to the login modal if absent. + +--- + +## `api.js` — HTTP Client + +Central API client. All page scripts call these functions; none call `fetch()` directly. + +### Core functions + +| Function | Description | +|---|---| +| `apiGet(url)` | GET, returns parsed JSON | +| `apiPost(url, body)` | POST JSON | +| `apiPatch(url, body)` | PATCH JSON | +| `apiDelete(url)` | DELETE | +| `apiFetch(url, opts)` | Raw fetch with JWT header + auto-refresh | + +### JWT handling + +- Access token stored in `localStorage` as `access`. +- Refresh token stored in `localStorage` as `refresh`. +- On 401, `apiFetch` automatically calls `/api/auth/token/refresh/`, stores the new access token, and retries the original request once. +- On refresh failure (401 again), clears storage and redirects to login. + +### `asList(data)` + +Unwraps DRF paginated responses: + +```js +asList(data) // → data.results if paginated, data if already an array +``` + +Used throughout page scripts after every list endpoint call. + +--- + +## `utils.js` — Shared UI Utilities + +Shared helpers used by all page scripts. Must be loaded after `api.js`. + +### `showToast(msg, type = 'success')` + +Injects a Bootstrap toast into `#toastContainer` and auto-dismisses it. Type maps to Bootstrap contextual colours (`success`, `danger`, `warning`, `info`). + +Every HTML page includes `
` at the bottom. + +### `esc(s)` + +HTML-escapes a string. Used when injecting user-controlled text into `innerHTML`. + +### Unit system + +Units are stored in `localStorage`: + +| Function | Key | Values | +|---|---|---| +| `getDistUnit()` / `setDistUnit(u)` | `distUnit` | `'mm'` (default), `'moa'`, `'mrad'` | +| `getVelUnit()` / `setVelUnit(u)` | `velUnit` | `'ms'` (default), `'fps'` | + +### `fDist(mm, moa, distM)` + +Formats a distance value according to the current unit: +- `mm` → shows `X mm` +- `moa` → shows `X MOA` (requires `moa` param to be non-null) +- `mrad` → converts mm to mrad using `distM` (requires `distM` in metres) + +Falls back to mm when MOA/MRAD data is unavailable. + +### `applyDistUnitButtons(container)` + +Marks the active `[data-dist-unit]` button within a container by adding/removing the Bootstrap `active` class. Call this whenever the unit changes or a modal opens. + +```js +applyDistUnitButtons(document.getElementById('myModal')); +``` + +### `renderPublicToggle(isPublic, { btnId, iconId, labelId, privateClass })` + +Updates a "Make public / Make private" toggle button's icon and label. The `privateClass` option sets the button variant when private (default `'btn-outline-secondary'`; pass `'btn-outline-light'` inside dark modals). + +--- + +## `i18n.js` — Translations + +Provides `t(key)` for client-side string lookup. Supported languages: `en`, `fr`, `de`, `es`. The active language is read from `localStorage` (set from the user's profile preference after login). + +--- + +## `nav.js` — Navbar + Auth Gate + +### Navbar + +Builds the `