made the long waited animation when add new truck or truck is completed. mouse over the list prevents the list from changing, only the animations work. moved top menu buttons to the right sidebar to free some vertical space. license text on button hover, not fixed above
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from api.models import User
|
||||
from vehicles.models import Vehicle
|
||||
|
||||
STATION = {'station_key': 'test-station'}
|
||||
|
||||
|
||||
class OneWeightTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user('op', password='x', can_measure=True)
|
||||
self.other = User.objects.create_user('first', password='x')
|
||||
self.client.force_authenticate(self.user)
|
||||
p = mock.patch('api.views._station_allowed', return_value=True)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch('api.views.sse_broadcast_update')
|
||||
self.sse = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def url(self, v):
|
||||
return f'/api/vehicles/{v.pk}/one-weight/'
|
||||
|
||||
def make(self, **kw):
|
||||
return Vehicle.objects.create(vehicle_number='CB1234', driver_name='D', **kw)
|
||||
|
||||
def test_moves_tare_to_gross_and_finalizes(self):
|
||||
when = timezone.now() - timedelta(hours=1)
|
||||
v = self.make(tare=1500, tare_date=when, tare_user=self.other, tare_manual=True,
|
||||
tare_manual_user=self.other, tare_manual_date=when, tare_manual_note='n')
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 200, r.content)
|
||||
v.refresh_from_db()
|
||||
self.assertEqual(v.gross, 1500)
|
||||
self.assertEqual(v.gross_date, when)
|
||||
self.assertEqual(v.gross_user, self.other)
|
||||
self.assertTrue(v.gross_manual)
|
||||
self.assertEqual(v.gross_manual_user, self.other)
|
||||
self.assertEqual(v.gross_manual_date, when)
|
||||
self.assertEqual(v.gross_manual_note, 'n')
|
||||
for f in ('tare', 'tare_date', 'tare_user', 'tare_manual_user',
|
||||
'tare_manual_date', 'tare_manual_note'):
|
||||
self.assertIsNone(getattr(v, f), f)
|
||||
self.assertFalse(v.tare_manual)
|
||||
self.assertEqual(v.net, 1500)
|
||||
self.assertEqual(v.net_user, self.user)
|
||||
self.assertIsNotNone(v.net_date)
|
||||
self.assertTrue(v.doc_number)
|
||||
self.assertEqual(r.data['doc_number'], v.doc_number)
|
||||
self.assertEqual(r.data['net'], 1500)
|
||||
self.assertIsNone(r.data['tare'])
|
||||
self.sse.assert_called_once()
|
||||
self.assertEqual(self.sse.call_args.args[:2], ('vehicle', 'updated'))
|
||||
|
||||
def test_gross_already_set(self):
|
||||
when = timezone.now()
|
||||
v = self.make(gross=9000, gross_date=when, gross_user=self.other)
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 200, r.content)
|
||||
v.refresh_from_db()
|
||||
self.assertEqual((v.gross, v.gross_user, v.gross_date), (9000, self.other, when))
|
||||
self.assertIsNone(v.tare)
|
||||
self.assertEqual(v.net, 9000)
|
||||
self.assertTrue(v.doc_number)
|
||||
|
||||
def _assert_400(self, v):
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertIn('detail', r.data)
|
||||
self.sse.assert_not_called()
|
||||
|
||||
def test_already_finalized(self):
|
||||
self._assert_400(self.make(gross=5, net=5, doc_number='2026-1'))
|
||||
|
||||
def test_void(self):
|
||||
self._assert_400(self.make(tare=5, is_void=True))
|
||||
|
||||
def test_no_weight(self):
|
||||
self._assert_400(self.make())
|
||||
|
||||
def test_both_weights(self):
|
||||
self._assert_400(self.make(tare=5, gross=10))
|
||||
|
||||
def test_permission_denied(self):
|
||||
v = self.make(tare=5)
|
||||
self.client.force_authenticate(self.other)
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 403)
|
||||
v.refresh_from_db()
|
||||
self.assertEqual(v.tare, 5)
|
||||
self.assertIsNone(v.doc_number)
|
||||
|
||||
def test_station_not_licensed(self):
|
||||
v = self.make(tare=5)
|
||||
with mock.patch('api.views._station_allowed', return_value=False):
|
||||
r = self.client.post(self.url(v), STATION, format='json')
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_unknown_vehicle_404(self):
|
||||
r = self.client.post('/api/vehicles/999999/one-weight/', STATION, format='json')
|
||||
self.assertEqual(r.status_code, 404)
|
||||
+71
-3
@@ -2,6 +2,7 @@ from rest_framework import viewsets, status, mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from .models import (ComPortReading, User, Report, ScaleProtocol, License,
|
||||
StationSlot, generate_doc_number)
|
||||
@@ -11,6 +12,7 @@ from .serializers import (
|
||||
NomenclatureEntrySerializer, ReportSerializer, ExtraCombinationSerializer,
|
||||
ScaleProtocolSerializer,
|
||||
)
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from vehicles.models import Vehicle, ExtraCombination, combination_hash
|
||||
from nomenclatures.models import Nomenclature, NomenclatureEntry
|
||||
@@ -131,6 +133,24 @@ class ComPortReadingViewSet(viewsets.ModelViewSet):
|
||||
serializer.save(source_ip=self.get_client_ip(self.request))
|
||||
|
||||
|
||||
_WEIGHING_SUFFIXES = ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note')
|
||||
|
||||
|
||||
def _move_weighing(vehicle, src_prefix, dst_prefix):
|
||||
"""Copy a recorded weighing to the other slot, carrying all its provenance.
|
||||
|
||||
The source slot is left untouched (callers overwrite or clear it as needed).
|
||||
"""
|
||||
for suffix in _WEIGHING_SUFFIXES:
|
||||
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
|
||||
|
||||
|
||||
def _clear_weighing(vehicle, prefix):
|
||||
"""Reset every field of a weighing slot to its empty state."""
|
||||
for suffix in _WEIGHING_SUFFIXES:
|
||||
setattr(vehicle, f'{prefix}{suffix}', False if suffix == '_manual' else None)
|
||||
|
||||
|
||||
def _maybe_finalize(vehicle, user):
|
||||
"""If both tare and gross are set and net hasn't been calculated yet, compute net and assign doc_number."""
|
||||
if vehicle.tare is not None and vehicle.gross is not None and vehicle.net is None:
|
||||
@@ -159,9 +179,7 @@ def _assign_weighing(vehicle, value, user, manual=False, note=None):
|
||||
setattr(vehicle, f'{prefix}_manual_note', note if manual else None)
|
||||
|
||||
def move(src_prefix, dst_prefix):
|
||||
"""Move a recorded weighing to the other slot, carrying all its provenance."""
|
||||
for suffix in ('', '_date', '_user', '_manual', '_manual_user', '_manual_date', '_manual_note'):
|
||||
setattr(vehicle, f'{dst_prefix}{suffix}', getattr(vehicle, f'{src_prefix}{suffix}'))
|
||||
_move_weighing(vehicle, src_prefix, dst_prefix)
|
||||
|
||||
if vehicle.tare is not None and vehicle.gross is not None:
|
||||
raise ValueError('Vehicle already has both weights.')
|
||||
@@ -231,6 +249,7 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
destroy: Disabled - vehicles are never deleted, see void() instead
|
||||
by_number: Get vehicle by vehicle_number
|
||||
measure: Record a weighing (app assigns tare vs gross by weight)
|
||||
one_weight: Finalize a vehicle weighed once (weight becomes gross, net = gross)
|
||||
void: Flag a completed vehicle as voided (corrective reversal, not a delete)
|
||||
mark_notice_printed: Signal that a completed vehicle's notice was printed/downloaded
|
||||
"""
|
||||
@@ -333,6 +352,55 @@ class VehicleViewSet(viewsets.ModelViewSet):
|
||||
sse_broadcast_update('vehicle', 'updated', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='one-weight')
|
||||
def one_weight(self, request, pk=None):
|
||||
"""Finalize a vehicle weighed only once (external clients).
|
||||
|
||||
The single weight is treated as gross: net = gross, tare stays empty.
|
||||
"""
|
||||
if not request.user.can_measure:
|
||||
return Response({'detail': 'You do not have permission to measure.'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
station_key = (request.data.get('station_key') or '').strip()
|
||||
if not _station_allowed(station_key):
|
||||
limit = licensing.effective_station_limit()
|
||||
return Response(
|
||||
{'detail': f'This station is not licensed to measure '
|
||||
f'(limit: {limit} stations).'},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
with transaction.atomic():
|
||||
vehicle = get_object_or_404(self.get_queryset().select_for_update(of=('self',)),
|
||||
pk=pk)
|
||||
self.check_object_permissions(request, vehicle)
|
||||
|
||||
if vehicle.doc_number:
|
||||
return Response({'detail': 'This vehicle is already finalized.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
if vehicle.is_void:
|
||||
return Response({'detail': 'A voided vehicle cannot be finalized.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
if (vehicle.tare is None) == (vehicle.gross is None):
|
||||
return Response(
|
||||
{'detail': 'Exactly one weight must be recorded to finalize with one weight.'},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if vehicle.tare is not None:
|
||||
_move_weighing(vehicle, 'tare', 'gross')
|
||||
_clear_weighing(vehicle, 'tare')
|
||||
|
||||
now = timezone.now()
|
||||
vehicle.net = vehicle.gross
|
||||
vehicle.net_date = now
|
||||
vehicle.net_user = request.user
|
||||
vehicle.doc_number = generate_doc_number(now.year)
|
||||
vehicle.save()
|
||||
|
||||
serializer = self.get_serializer(vehicle)
|
||||
sse_broadcast_update('vehicle', 'updated', serializer.data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='void')
|
||||
def void(self, request, pk=None):
|
||||
"""Flag a completed vehicle as voided. Does not touch doc_number/net/tare/gross."""
|
||||
|
||||
Reference in New Issue
Block a user