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))