388 lines
14 KiB
Python
388 lines
14 KiB
Python
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<item_pk>[^/.]+)',
|
|
)
|
|
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=<id>.
|
|
"""
|
|
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))
|