First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
387
apps/gears/views.py
Normal file
387
apps/gears/views.py
Normal file
@@ -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<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))
|
||||
Reference in New Issue
Block a user