First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
319
apps/tools/views.py
Normal file
319
apps/tools/views.py
Normal file
@@ -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<group_pk>[^/.]+)',
|
||||
)
|
||||
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<group_pk>[^/.]+)/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<group_pk>[^/.]+)/shots/(?P<shot_pk>[^/.]+)',
|
||||
)
|
||||
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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user