Files

770 lines
30 KiB
Python

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)
from .serializers import (
ComPortReadingSerializer, UserSerializer, UserDetailSerializer,
ChangePasswordSerializer, VehicleSerializer, NomenclatureSerializer,
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
from scalesapp.sse import sse_broadcast_update
from . import licensing
class IsAdminUser(IsAuthenticated):
def has_permission(self, request, view):
return bool(request.user and request.user.is_authenticated and request.user.is_admin)
class UserViewSet(viewsets.ModelViewSet):
"""
API endpoint for user management.
list: Get all users
create: Create a new user
retrieve: Get a specific user
update: Update a user
destroy: Soft-delete a user (sets is_active=False)
me: Get current authenticated user
change_password: Change password for current user
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filterset_fields = ['role', 'is_admin', 'is_active']
ordering = ['username']
def get_permissions(self):
if self.action in ('me', 'change_password'):
return [IsAuthenticated()]
return [IsAuthenticated(), IsAdminUser()]
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
instance.is_active = False
instance.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=['get'])
def me(self, request):
"""Get current authenticated user details"""
serializer = UserDetailSerializer(request.user)
return Response(serializer.data)
@action(detail=False, methods=['post'],
url_path='change-password', url_name='change_password')
def change_password(self, request):
"""Change password for current user"""
serializer = ChangePasswordSerializer(
data=request.data,
context={'request': request}
)
if serializer.is_valid():
serializer.save()
return Response({
'detail': 'Password updated successfully'
}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ComPortReadingViewSet(viewsets.ModelViewSet):
"""
API endpoint for COM port readings.
list: Get all readings with optional filtering
create: Create a new reading (allows unauthenticated for serial bridge)
retrieve: Get a specific reading
destroy: Delete a reading
latest: Get the latest reading
by_port: Get readings for a specific port
"""
queryset = ComPortReading.objects.all()
serializer_class = ComPortReadingSerializer
filterset_fields = ['port']
ordering = ['-timestamp']
def get_permissions(self):
"""Allow unauthenticated POST for serial bridge"""
if self.action == 'create':
return [AllowAny()]
return [IsAuthenticated()]
@action(detail=False, methods=['get'])
def latest(self, request):
"""Get the latest reading"""
reading = ComPortReading.objects.first()
if reading:
serializer = self.get_serializer(reading)
return Response(serializer.data)
return Response({'detail': 'No readings yet'}, status=status.HTTP_404_NOT_FOUND)
@action(detail=False, methods=['get'])
def by_port(self, request):
"""Get readings for a specific port"""
port = request.query_params.get('port', None)
if not port:
return Response(
{'detail': 'port parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
readings = ComPortReading.objects.filter(port=port)
serializer = self.get_serializer(readings, many=True)
return Response(serializer.data)
def get_client_ip(self, request):
"""Get client IP address"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def perform_create(self, serializer):
"""Save the reading with client IP"""
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:
vehicle.net = vehicle.gross - vehicle.tare
vehicle.net_date = timezone.now()
vehicle.net_user = user
vehicle.doc_number = generate_doc_number(timezone.now().year)
vehicle.save()
def _assign_weighing(vehicle, value, user, manual=False, note=None):
"""Record a weighing. Assigns by weight: lighter becomes tare, heavier becomes gross.
The first weighing is parked in tare; the second may push it to gross if the new
reading is lighter, since trucks may arrive either empty (inbound) or loaded (outbound).
"""
now = timezone.now()
def apply(prefix):
setattr(vehicle, prefix, value)
setattr(vehicle, f'{prefix}_date', now)
setattr(vehicle, f'{prefix}_user', user)
setattr(vehicle, f'{prefix}_manual', manual)
setattr(vehicle, f'{prefix}_manual_user', user if manual else None)
setattr(vehicle, f'{prefix}_manual_date', now if manual else None)
setattr(vehicle, f'{prefix}_manual_note', note if manual else None)
def move(src_prefix, dst_prefix):
_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.')
if vehicle.tare is None and vehicle.gross is None:
apply('tare')
elif vehicle.gross is None:
# tare holds the first weighing - keep the lighter one as tare
if value >= vehicle.tare:
apply('gross')
else:
move('tare', 'gross')
apply('tare')
else:
# gross set but tare empty (only reachable via direct API use)
if value <= vehicle.gross:
apply('tare')
else:
move('gross', 'tare')
apply('gross')
vehicle.save()
_maybe_finalize(vehicle, user)
def _record_extra_combination(extra_data):
"""Record the lookup/selector projection of a vehicle's extra data as a reusable combination.
The selector value is part of the combination: without it, "Ship + Varna Star"
and "Warehouse + WH-3" would collapse into indistinguishable rows.
"""
if not extra_data:
return
lookup_codes = set(
Nomenclature.objects.filter(
applies_to='vehicle',
kind__in=[Nomenclature.LOOKUP, Nomenclature.SELECTOR],
)
.values_list('code', flat=True)
)
combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None}
if not combo:
return
existing, created = ExtraCombination.objects.get_or_create(
data_hash=combination_hash(combo), defaults={'data': combo}
)
if not created:
existing.use_count += 1
existing.last_used = timezone.now()
existing.save()
action = 'created' if created else 'updated'
sse_broadcast_update('extra_combination', action, ExtraCombinationSerializer(existing).data)
class VehicleViewSet(viewsets.ModelViewSet):
"""
API endpoint for vehicle management.
list: Get all vehicles with pagination
create: Create a new vehicle with optional extra data
retrieve: Get a specific vehicle by ID
update: Full update of vehicle and extra data
partial_update: Partial update of vehicle
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
"""
queryset = Vehicle.objects.select_related('extra', 'tare_user', 'gross_user').all()
serializer_class = VehicleSerializer
permission_classes = [IsAuthenticated]
filterset_fields = {
'vehicle_number': ['exact'],
'doc_number': ['isnull'],
}
search_fields = ['vehicle_number']
ordering = ['vehicle_number']
def perform_create(self, serializer):
instance = serializer.save()
data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'created', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def perform_update(self, serializer):
instance = serializer.save()
data = VehicleSerializer(instance).data
sse_broadcast_update('vehicle', 'updated', data)
_record_extra_combination(instance.extra.data if hasattr(instance, 'extra') else None)
def destroy(self, request, *args, **kwargs):
# Vehicles are never deleted - a completed weighing is corrected with
# /void/ instead, which keeps the record (and its doc_number) intact
# and visible in History. Overriding destroy(), not perform_destroy():
# ModelViewSet.destroy() calls perform_destroy() then always returns
# 204, so neutering only perform_destroy would silently no-op instead
# of telling the caller this action doesn't exist.
return Response(
{'detail': 'Vehicles cannot be deleted. Use /void/ on a completed vehicle instead.'},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
@action(detail=False, methods=['get'], url_path='by-number')
def by_number(self, request):
"""Get vehicle by vehicle_number query parameter"""
vehicle_number = request.query_params.get('vehicle_number', None)
if not vehicle_number:
return Response(
{'detail': 'vehicle_number parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
vehicle = Vehicle.objects.get(vehicle_number=vehicle_number)
serializer = self.get_serializer(vehicle)
return Response(serializer.data)
except Vehicle.DoesNotExist:
return Response(
{'detail': 'Vehicle not found'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=['post'], url_path='measure')
def measure(self, request, pk=None):
"""Record a weighing. The app assigns tare vs gross by weight."""
if not request.user.can_measure:
return Response({'detail': 'You do not have permission to measure.'},
status=status.HTTP_403_FORBIDDEN)
# Licence enforcement. The UI also disables the button, but this is the
# actual rule - the button state is only a convenience.
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)
manual = bool(request.data.get('manual', False))
if manual and not request.user.can_manually_measure:
return Response({'detail': 'You do not have permission to measure manually.'},
status=status.HTTP_403_FORBIDDEN)
note = (request.data.get('note') or '').strip()
if manual and not note:
return Response({'detail': 'A note is required for a manual measurement.'},
status=status.HTTP_400_BAD_REQUEST)
value = request.data.get('value')
if value is None:
return Response({'detail': 'value is required'}, status=status.HTTP_400_BAD_REQUEST)
try:
value = int(value)
except (ValueError, TypeError):
return Response({'detail': 'value must be a number'}, status=status.HTTP_400_BAD_REQUEST)
vehicle = self.get_object()
try:
_assign_weighing(vehicle, value, request.user, manual=manual, note=note or None)
except ValueError as exc:
return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
serializer = self.get_serializer(vehicle)
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."""
if not request.user.can_edit_documents:
return Response({'detail': 'You do not have permission to void a document.'},
status=status.HTTP_403_FORBIDDEN)
vehicle = self.get_object()
if not vehicle.doc_number:
return Response({'detail': 'Only a completed vehicle can be voided.'},
status=status.HTTP_400_BAD_REQUEST)
reason = (request.data.get('reason') or '').strip()
if not reason:
return Response({'detail': 'A reason is required to void a document.'},
status=status.HTTP_400_BAD_REQUEST)
vehicle.is_void = True
vehicle.void_date = timezone.now()
vehicle.void_user = request.user
vehicle.void_reason = reason
vehicle.save()
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'voided', serializer.data)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='mark-notice-printed')
def mark_notice_printed(self, request, pk=None):
"""Signal that the operator has acted on a completed vehicle's notice (print/download).
Makes no DB change - doc_number already marks the vehicle as finalized.
This only broadcasts the SSE event that removes it from the active list.
"""
if not request.user.can_measure:
return Response({'detail': 'You do not have permission to do this.'},
status=status.HTTP_403_FORBIDDEN)
vehicle = self.get_object()
if not vehicle.doc_number:
return Response({'detail': 'Only a completed vehicle can be archived.'},
status=status.HTTP_400_BAD_REQUEST)
serializer = self.get_serializer(vehicle)
sse_broadcast_update('vehicle', 'archived', serializer.data)
return Response(serializer.data)
class ExtraCombinationViewSet(mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet):
"""
API endpoint for previously-used combinations of vehicle extra (lookup) data.
list: Get all combinations, most recently used first
retrieve: Get a specific combination
destroy: Delete a combination the user no longer needs
Rows are created/bumped automatically when a vehicle's extra data is saved.
"""
queryset = ExtraCombination.objects.all()
serializer_class = ExtraCombinationSerializer
permission_classes = [IsAuthenticated]
ordering = ['-last_used']
def perform_destroy(self, instance):
data = ExtraCombinationSerializer(instance).data
instance.delete()
sse_broadcast_update('extra_combination', 'deleted', data)
def _live_slots():
return StationSlot.objects.filter(last_seen__gte=licensing.live_slot_cutoff())
def _station_allowed(station_key):
"""Whether this station may measure.
A station already holding a live slot is always allowed - including when the
licence is full - so a brief disconnection does not lock a working station
out behind its own expiring slot.
"""
if not station_key:
return False
if _live_slots().filter(station_key=station_key).exists():
return True
return _live_slots().count() < licensing.effective_station_limit()
class LicenseView(APIView):
"""Licence status, and installing a new licence key."""
permission_classes = [IsAuthenticated]
def get(self, request):
status_info = licensing.license_status()
slots = _live_slots()
payload = {
**status_info,
'stations_used': slots.count(),
'components': licensing.machine_components(),
}
if request.user.is_admin:
payload['slots'] = [
{'station_key': s.station_key, 'com_port': s.com_port,
'bridge_version': s.bridge_version, 'last_seen': s.last_seen}
for s in slots
]
return Response(payload)
def post(self, request):
if not request.user.is_admin:
return Response({'detail': 'Only administrators can install a licence.'},
status=status.HTTP_403_FORBIDDEN)
blob = (request.data.get('key_blob') or '').strip()
if not blob:
return Response({'detail': 'key_blob is required'},
status=status.HTTP_400_BAD_REQUEST)
# Validate before storing, so a bad paste cannot displace a working licence.
valid, payload, reason = licensing.verify_license(blob)
if not valid:
return Response({'detail': reason}, status=status.HTTP_400_BAD_REQUEST)
record = License.objects.first() or License()
record.key_blob = blob
record.grace_started = None
record.save()
return Response(licensing.license_status())
class LicenseHeartbeatView(APIView):
"""Stations report in here so the server can count them.
The bridge deliberately does not talk to Django - the browser, which already
talks to both, reports which bridge it is using. That keeps the bridge pure
transport with no startup dependency on the server.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
station_key = (request.data.get('station_key') or '').strip()
if not station_key:
return Response({'detail': 'station_key is required'},
status=status.HTTP_400_BAD_REQUEST)
limit = licensing.effective_station_limit()
existing = StationSlot.objects.filter(station_key=station_key).first()
live = _live_slots()
if existing is None and live.count() >= limit:
return Response({
'allowed': False,
'stations_used': live.count(),
'stations_licensed': limit,
'detail': f'Licence limit reached ({limit} stations)',
}, status=status.HTTP_403_FORBIDDEN)
slot, _ = StationSlot.objects.update_or_create(
station_key=station_key,
defaults={
'bridge_version': request.data.get('bridge_version', '') or '',
'com_port': request.data.get('com_port', '') or '',
},
)
# update_or_create does not touch auto_now when nothing changed
slot.save(update_fields=['last_seen'])
return Response({
'allowed': True,
'stations_used': _live_slots().count(),
'stations_licensed': limit,
})
class ScaleProtocolViewSet(viewsets.ModelViewSet):
"""
API endpoint for scale protocol definitions.
The frontend caches these and uses them to parse raw serial lines, so adding
a scale type needs no change to the serial bridge running on station PCs.
Built-in protocols cannot be deleted.
"""
queryset = ScaleProtocol.objects.all()
serializer_class = ScaleProtocolSerializer
permission_classes = [IsAuthenticated]
search_fields = ['name']
ordering = ['name']
def perform_create(self, serializer):
instance = serializer.save()
sse_broadcast_update('scale_protocol', 'created', ScaleProtocolSerializer(instance).data)
def perform_update(self, serializer):
instance = serializer.save()
sse_broadcast_update('scale_protocol', 'updated', ScaleProtocolSerializer(instance).data)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.is_builtin:
return Response({'detail': 'Built-in protocols cannot be deleted.'},
status=status.HTTP_400_BAD_REQUEST)
data = ScaleProtocolSerializer(instance).data
instance.delete()
sse_broadcast_update('scale_protocol', 'deleted', data)
return Response(status=status.HTTP_204_NO_CONTENT)
class NomenclatureViewSet(viewsets.ModelViewSet):
"""
API endpoint for nomenclature management.
list: Get all nomenclatures with pagination
create: Create a new nomenclature with fields
retrieve: Get a specific nomenclature by ID
update: Full update of nomenclature and fields
partial_update: Partial update of nomenclature
destroy: Delete a nomenclature (cascades to fields and entries)
by_code: Get nomenclature by code
by_applies_to: Filter nomenclatures by applies_to type
entries: List or create entries for a specific nomenclature
"""
queryset = Nomenclature.objects.prefetch_related('fields').all()
serializer_class = NomenclatureSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['applies_to', 'code', 'kind']
search_fields = ['code', 'name']
ordering = ['sort_order', 'code']
def perform_create(self, serializer):
instance = serializer.save()
sse_broadcast_update('nomenclature', 'insert', NomenclatureSerializer(instance).data)
def perform_update(self, serializer):
instance = serializer.save()
sse_broadcast_update('nomenclature', 'update', NomenclatureSerializer(instance).data)
def perform_destroy(self, instance):
data = NomenclatureSerializer(instance).data
instance.delete()
sse_broadcast_update('nomenclature', 'delete', data)
@action(detail=False, methods=['get'], url_path='by-code')
def by_code(self, request):
"""Get nomenclature by code query parameter"""
code = request.query_params.get('code', None)
if not code:
return Response(
{'detail': 'code parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
nomenclature = Nomenclature.objects.get(code=code)
serializer = self.get_serializer(nomenclature)
return Response(serializer.data)
except Nomenclature.DoesNotExist:
return Response(
{'detail': 'Nomenclature not found'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=False, methods=['get'], url_path='by-applies-to')
def by_applies_to(self, request):
"""Get nomenclatures filtered by applies_to query parameter"""
applies_to = request.query_params.get('applies_to', None)
if not applies_to:
return Response(
{'detail': 'applies_to parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
if applies_to not in ['vehicle', 'container']:
return Response(
{'detail': 'applies_to must be either vehicle or container'},
status=status.HTTP_400_BAD_REQUEST
)
nomenclatures = Nomenclature.objects.filter(applies_to=applies_to)
page = self.paginate_queryset(nomenclatures)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(nomenclatures, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get', 'post'], url_path='entries')
def entries(self, request, pk=None):
"""List or create entries for a specific nomenclature."""
nomenclature = self.get_object()
if request.method == 'GET':
entries = nomenclature.entries.all()
is_active = request.query_params.get('is_active')
if is_active is not None:
entries = entries.filter(is_active=is_active.lower() == 'true')
serializer = NomenclatureEntrySerializer(entries, many=True)
return Response(serializer.data)
serializer = NomenclatureEntrySerializer(
data=request.data,
context={'nomenclature': nomenclature},
)
serializer.is_valid(raise_exception=True)
serializer.save(nomenclature=nomenclature)
entry_data = {**serializer.data, 'nomenclature_code': nomenclature.code}
sse_broadcast_update('nomenclature_entry', 'insert', entry_data)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class NomenclatureEntryViewSet(viewsets.ModelViewSet):
"""
API endpoint for individual nomenclature entry management.
For list/create use the nested endpoint: /api/nomenclatures/{id}/entries/
This viewset handles retrieve/update/delete of individual entries.
"""
queryset = NomenclatureEntry.objects.select_related('nomenclature').all()
serializer_class = NomenclatureEntrySerializer
permission_classes = [IsAuthenticated]
def get_serializer_context(self):
context = super().get_serializer_context()
if self.kwargs.get('pk'):
try:
entry = NomenclatureEntry.objects.select_related('nomenclature').get(pk=self.kwargs['pk'])
context['nomenclature'] = entry.nomenclature
except NomenclatureEntry.DoesNotExist:
pass
return context
def perform_update(self, serializer):
instance = serializer.save()
entry_data = {**NomenclatureEntrySerializer(instance).data, 'nomenclature_code': instance.nomenclature.code}
sse_broadcast_update('nomenclature_entry', 'update', entry_data)
def perform_destroy(self, instance):
entry_data = {**NomenclatureEntrySerializer(instance).data, 'nomenclature_code': instance.nomenclature.code}
instance.delete()
sse_broadcast_update('nomenclature_entry', 'delete', entry_data)
class ReportViewSet(viewsets.ModelViewSet):
"""
API endpoint for report template management.
list: Get all reports
create: Create a new report
retrieve: Get a specific report by ID
update: Update a report
destroy: Delete a report
"""
queryset = Report.objects.select_related('created_by').all()
serializer_class = ReportSerializer
permission_classes = [IsAuthenticated]
filterset_fields = ['name', 'kind']
search_fields = ['name']
ordering = ['-updated_at']
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
def perform_update(self, serializer):
serializer.save()