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

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