Coverage for src/api/views.py: 84%
335 statements
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-08 10:41 +0000
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-08 10:41 +0000
1from django.db import transaction
2from django.shortcuts import get_object_or_404
3from django.contrib.auth.models import User
4from django.utils import timezone
5from django.db.models import ProtectedError
6from rest_framework import viewsets, mixins, status, permissions
7from rest_framework.decorators import action
8from rest_framework.response import Response
9from rest_framework.permissions import IsAuthenticated, AllowAny
10from drf_spectacular.utils import (
11 extend_schema, OpenApiParameter, OpenApiResponse, OpenApiExample
12)
13from django.utils.dateparse import parse_datetime
14from .permissions import IsLotOperator
15from .models import ParkingLot, Spot, Booking, OperatorProfile
16from .serializers import (
17 ParkingLotSerializer, ParkingLotDetailSerializer, SpotSerializer,
18 BookingSerializer, BookingCreateSerializer, BookingCancelSerializer,
19 UserRegistrationSerializer, UserSerializer, UserProfileUpdateSerializer,
20 OperatorBookingCancelSerializer, SpotOperatorUpdateSerializer,
21 OperatorAssignSerializer
22)
23from .validators import validate_booking_window
24from .swagger import ErrorSerializer
25from .services import PaymentService, BookingNotificationService, CancellationService, SpotUpdateService
26from rest_framework.exceptions import ValidationError
29class ParkingLotViewSet(viewsets.ModelViewSet):
30 queryset = ParkingLot.objects.all().prefetch_related("spots").order_by('name')
32 def get_serializer_class(self):
33 if self.action == 'list':
34 return ParkingLotSerializer
35 return ParkingLotDetailSerializer
37 def get_permissions(self):
38 if self.action in ['list', 'retrieve']:
39 self.permission_classes = [AllowAny]
40 else:
41 self.permission_classes = [IsAuthenticated, permissions.IsAdminUser]
42 return super().get_permissions()
44 @extend_schema(summary="[Admin] Create a new lot")
45 def create(self, request, *args, **kwargs):
46 return super().create(request, *args, **kwargs)
48 @extend_schema(summary="[Admin] Update a lot")
49 def update(self, request, *args, **kwargs):
50 return super().update(request, *args, **kwargs)
52 @extend_schema(summary="[Admin] Partially update a lot")
53 def partial_update(self, request, *args, **kwargs):
54 return super().partial_update(request, *args, **kwargs)
56 @extend_schema(summary="[Admin] Delete a lot")
57 def destroy(self, request, *args, **kwargs):
58 return super().destroy(request, *args, **kwargs)
60 def perform_destroy(self, instance):
61 active_bookings = Booking.objects.filter(
62 spot__lot=instance,
63 status='confirmed',
64 end_at__gt=timezone.now()
65 ).exists()
67 if active_bookings:
68 raise ValidationError(
69 {'detail': 'Cannot delete lot. It has spots with active or future bookings.'},
70 code='active_bookings_exist'
71 )
73 try:
74 instance.delete()
75 except ProtectedError:
76 raise ValidationError(
77 {'detail': 'Cannot delete lot. Its spots have a history of past bookings.'},
78 code='past_bookings_exist'
79 )
81 @extend_schema(
82 summary="List of all lots",
83 description="Return list of all available lots with base info (/api/v1/lots/).",
84 responses={
85 200: ParkingLotSerializer(many=True),
86 },
87 )
88 def list(self, request, *args, **kwargs):
89 return super().list(request, *args, **kwargs)
91 @extend_schema(
92 summary="Detailed info about lot",
93 description="Returns detailed information about a specific parking lot, including a list of parking spots (/api/v1/lots/{id}/).",
94 responses={
95 200: ParkingLotDetailSerializer,
96 404: OpenApiResponse(ErrorSerializer, description="Lot not found"),
97 },
98 )
99 def retrieve(self, request, *args, **kwargs):
100 return super().retrieve(request, *args, **kwargs)
103class SpotViewSet(mixins.ListModelMixin,
104 mixins.RetrieveModelMixin,
105 mixins.DestroyModelMixin,
106 viewsets.GenericViewSet):
107 serializer_class = SpotSerializer
109 def get_queryset(self):
110 lot_pk = self.kwargs.get('lot_pk')
111 return Spot.objects.filter(lot_id=lot_pk).select_related('lot').order_by('id')
113 def get_permissions(self):
114 if self.action in ['list', 'retrieve']:
115 self.permission_classes = [AllowAny]
116 else:
117 self.permission_classes = [IsAuthenticated, (permissions.IsAdminUser | IsLotOperator)]
118 return super().get_permissions()
120 @extend_schema(
121 summary="List of parking spots in a lot",
122 description="List of all parking spots in a specific parking lot. "
123 "You can filter by type (EV, for disabled) and availability.",
124 parameters=[
125 OpenApiParameter(
126 name="is_ev", required=False, type=bool,
127 description="Filter: spots with EV charging (true/false)"
128 ),
129 OpenApiParameter(
130 name="is_disabled", required=False, type=bool,
131 description="Filter: spots for people with disabilities (true/false)"
132 ),
133 OpenApiParameter(
134 name="available_from", required=False, type=str,
135 description="ISO datetime - show only available spots from this time"
136 ),
137 OpenApiParameter(
138 name="available_to", required=False, type=str,
139 description="ISO datetime - show only available spots until this time"
140 ),
141 ],
142 responses={
143 200: SpotSerializer(many=True),
144 400: OpenApiResponse(ErrorSerializer, description="Invalid filter parameters"),
145 404: OpenApiResponse(ErrorSerializer, description="Parking lot not found"),
146 }
147 )
148 def list(self, request, *args, **kwargs):
149 qs = self.get_queryset()
151 def parse_bool(val, key):
152 if val is None:
153 return None
154 low = val.lower()
155 if low not in ("true", "false"):
156 raise ValueError(f"The parameter '{key}' must be 'true' or 'false'")
157 return low == "true"
159 try:
160 ev = parse_bool(request.query_params.get("is_ev"), "is_ev")
161 dis = parse_bool(request.query_params.get("is_disabled"), "is_disabled")
162 except ValueError as e:
163 return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
165 if ev is not None:
166 qs = qs.filter(is_ev=ev)
167 if dis is not None:
168 qs = qs.filter(is_disabled=dis)
170 available_from = request.query_params.get("available_from")
171 available_to = request.query_params.get("available_to")
173 if available_from and available_to:
174 start = parse_datetime(available_from)
175 end = parse_datetime(available_to)
177 if not start or not end:
178 return Response(
179 {"detail": "Invalid date format. Use ISO 8601 format."},
180 status=status.HTTP_400_BAD_REQUEST
181 )
183 booked_spots = Booking.objects.filter(
184 status="confirmed",
185 start_at__lt=end,
186 end_at__gt=start
187 ).values_list('spot_id', flat=True)
189 qs = qs.exclude(id__in=booked_spots)
191 self.queryset = qs
192 return super().list(request, *args, **kwargs)
194 @extend_schema(
195 summary="Get parking spot details",
196 description="Returns detailed information for a single parking spot.",
197 responses={
198 200: SpotSerializer,
199 404: OpenApiResponse(ErrorSerializer, description="Spot not found or Lot not found"),
200 }
201 )
202 def retrieve(self, request, *args, **kwargs):
203 return super().retrieve(request, *args, **kwargs)
205 @extend_schema(
206 summary="[Operator] Create a new spot",
207 description="Allows an operator to create a new parking spot within their assigned lot.",
208 request=SpotSerializer,
209 responses={
210 201: SpotSerializer,
211 400: OpenApiResponse(ErrorSerializer, description="Validation Error (e.g., duplicate number)"),
212 403: OpenApiResponse(ErrorSerializer, description="Forbidden - Not an operator for this lot"),
213 }
214 )
215 @action(
216 detail=False,
217 methods=["post"],
218 url_path="create")
219 @transaction.atomic
220 def create_spot(self, request, lot_pk=None):
221 lot = get_object_or_404(ParkingLot, pk=lot_pk)
223 if not request.user.is_staff:
224 operator_profile = getattr(request.user, "operator_profile", None)
225 if not operator_profile or operator_profile.lot_id != int(lot_pk):
226 return Response(
227 {"detail": "You cannot create spots in another lot."},
228 status=status.HTTP_403_FORBIDDEN,
229 )
231 number = request.data.get("number")
232 if Spot.objects.filter(lot=lot, number__iexact=number).exists():
233 raise ValidationError({"number": "This spot number already exists in this lot."})
235 serializer = SpotSerializer(data=request.data, context={"lot": lot})
236 serializer.is_valid(raise_exception=True)
237 serializer.save(lot=lot, created_by=request.user)
239 response_data = serializer.data
240 response_data["lot_name"] = lot.name
241 return Response(response_data, status=status.HTTP_201_CREATED)
243 @extend_schema(
244 summary="[Operator] Update spot properties",
245 description="Allows an operator to update limited fields of a spot (e.g., 'is_ev', 'is_disabled').",
246 request=SpotOperatorUpdateSerializer,
247 responses={
248 200: SpotOperatorUpdateSerializer,
249 400: OpenApiResponse(ErrorSerializer, description="Validation Error (e.g., trying to change number)"),
250 403: OpenApiResponse(ErrorSerializer, description="Forbidden - Not an operator for this lot"),
251 }
252 )
253 @action(detail=True, methods=["patch"], url_path="operator-update",
254 permission_classes=[IsAuthenticated, IsLotOperator])
255 @transaction.atomic
256 def operator_update(self, request, lot_pk=None, pk=None):
257 spot = self.get_object()
258 serializer = SpotOperatorUpdateSerializer(spot, data=request.data, partial=True)
259 serializer.is_valid(raise_exception=True)
260 SpotUpdateService.update_spot(spot, serializer.validated_data)
261 return Response(serializer.data)
263 @extend_schema(
264 summary="[Operator] Delete a parking spot",
265 description="Deletes a parking spot. Fails if the spot has any active (confirmed and future) bookings or protected past bookings.",
266 responses={
267 204: OpenApiResponse(description="Spot deleted successfully"),
268 400: OpenApiResponse(ErrorSerializer, description="Cannot delete spot with active/past bookings"),
269 403: OpenApiResponse(ErrorSerializer, description="Forbidden - Not an operator for this lot"),
270 404: OpenApiResponse(ErrorSerializer, description="Spot not found"),
271 }
272 )
273 def destroy(self, request, *args, **kwargs):
274 return super().destroy(request, *args, **kwargs)
276 def perform_destroy(self, instance):
277 active_bookings = Booking.objects.filter(
278 spot=instance,
279 status='confirmed',
280 end_at__gt=timezone.now()
281 )
283 if active_bookings.exists():
284 raise ValidationError(
285 {'detail': 'Cannot delete spot. There are active or future bookings associated with it.'},
286 code='active_bookings_exist'
287 )
289 try:
290 instance.delete()
291 except ProtectedError:
292 raise ValidationError(
293 {'detail': 'Cannot delete spot. It has a history of past bookings and is protected from deletion.'},
294 code='past_bookings_exist'
295 )
298class BookingViewSet(mixins.ListModelMixin,
299 mixins.RetrieveModelMixin,
300 viewsets.GenericViewSet):
301 serializer_class = BookingSerializer
302 permission_classes = [IsAuthenticated]
304 def get_queryset(self):
305 qs = Booking.objects.all().select_related("spot__lot", "user").order_by("-created_at")
307 if not self.request.user.is_authenticated:
308 return Booking.objects.none()
310 if self.request.user.is_staff or self.action in ['my_lot_bookings', 'cancel_by_operator']:
311 return qs
313 return qs.filter(user=self.request.user)
315 def get_object(self):
316 obj = super().get_object()
317 self.check_object_permissions(self.request, obj)
318 return obj
320 @extend_schema(
321 summary="[Client] List my bookings",
322 description="Returns a list of all bookings for the *current authenticated user*.",
323 parameters=[
324 OpenApiParameter(
325 name="status", required=False, type=str, enum=["confirmed", "cancelled"],
326 description="Filter by booking status"
327 ),
328 ],
329 responses={
330 200: BookingSerializer(many=True),
331 401: OpenApiResponse(ErrorSerializer, description="Authentication required"),
332 },
333 )
334 def list(self, request, *args, **kwargs):
335 status_filter = request.query_params.get("status")
336 qs = self.get_queryset()
338 if status_filter in ["confirmed", "cancelled"]:
339 qs = qs.filter(status=status_filter)
340 elif status_filter:
341 return Response(
342 {"detail": "Invalid status. Allowed: confirmed, cancelled"},
343 status=status.HTTP_400_BAD_REQUEST
344 )
346 self.queryset = qs
347 return super().list(request, *args, **kwargs)
349 @extend_schema(
350 summary="[Client] Get booking details",
351 description="Returns detailed information about a specific booking belonging to the *current user*.",
352 responses={
353 200: BookingSerializer,
354 404: OpenApiResponse(ErrorSerializer, description="Booking not found or belongs to another user"),
355 401: OpenApiResponse(ErrorSerializer, description="Authentication required"),
356 },
357 )
358 def retrieve(self, request, *args, **kwargs):
359 return super().retrieve(request, *args, **kwargs)
361 @extend_schema(
362 summary="[Client] Create a new booking",
363 description="Creates a new parking spot booking. A mock payment process is initiated upon creation.",
364 request=BookingCreateSerializer,
365 responses={
366 201: BookingSerializer,
367 400: OpenApiResponse(ErrorSerializer, description="Invalid time range or data"),
368 401: OpenApiResponse(ErrorSerializer, description="Authentication required"),
369 409: OpenApiResponse(ErrorSerializer, description="The spot is already booked for this period"),
370 },
371 examples=[
372 OpenApiExample(
373 "Valid request",
374 value={
375 "spot": 10,
376 "start_at": "2025-10-15T10:00:00Z",
377 "end_at": "2025-10-15T12:00:00Z"
378 },
379 ),
380 ]
381 )
382 @action(detail=False, methods=["post"], url_path="create")
383 @transaction.atomic
384 def create_booking(self, request):
385 ser = BookingCreateSerializer(data=request.data)
386 ser.is_valid(raise_exception=True)
388 spot = ser.validated_data["spot"]
389 start_at = ser.validated_data["start_at"]
390 end_at = ser.validated_data["end_at"]
392 validate_booking_window(start_at, end_at)
394 conflict = Booking.objects.filter(
395 spot=spot,
396 status="confirmed",
397 start_at__lt=end_at,
398 end_at__gt=start_at
399 ).exists()
401 if conflict:
402 return Response(
403 {"detail": "This spot is already booked for the specified period."},
404 status=status.HTTP_409_CONFLICT
405 )
407 booking = Booking.objects.create(
408 user=request.user,
409 spot=spot,
410 start_at=start_at,
411 end_at=end_at,
412 status="confirmed"
413 )
415 payment_data = PaymentService.initiate_payment(booking)
416 response_data = BookingSerializer(booking).data
417 response_data["payment"] = payment_data
419 return Response(response_data, status=status.HTTP_201_CREATED)
421 @extend_schema(
422 summary="[Client] Cancel my booking",
423 description="Cancels an existing booking *belonging to the current user*. A mock refund is triggered.",
424 request=BookingCancelSerializer,
425 responses={
426 200: BookingSerializer,
427 400: OpenApiResponse(ErrorSerializer, description="Booking already cancelled"),
428 401: OpenApiResponse(ErrorSerializer, description="Authentication required"),
429 404: OpenApiResponse(ErrorSerializer, description="Booking not found or belongs to another user"),
430 },
431 examples=[
432 OpenApiExample(
433 "Valid request",
434 value={"reason": "Changed plans"}
435 ),
436 ]
437 )
438 @action(detail=True, methods=["post"], url_path="cancel")
439 @transaction.atomic
440 def cancel(self, request, pk=None):
441 booking = get_object_or_404(
442 Booking,
443 pk=pk,
444 user=request.user
445 )
447 if booking.status == "cancelled":
448 return Response(
449 {"detail": "This booking has already been cancelled."},
450 status=status.HTTP_400_BAD_REQUEST
451 )
453 cancel_serializer = BookingCancelSerializer(data=request.data)
454 cancel_serializer.is_valid(raise_exception=True)
455 reason = cancel_serializer.validated_data.get("reason", "")
457 booking.status = "cancelled"
458 booking.cancellation_reason = reason
459 booking.save(update_fields=["status", "cancellation_reason"])
460 PaymentService.process_refund(booking)
461 return Response(BookingSerializer(booking).data)
463 @extend_schema(
464 summary="[Operator] List bookings for my lot",
465 description="Returns a list of all bookings for the parking lot *assigned to the authenticated operator*.",
466 responses={
467 200: BookingSerializer(many=True),
468 403: OpenApiResponse(ErrorSerializer, description="Forbidden - You are not an operator or not assigned to a lot"),
469 }
470 )
471 @action(detail=False, methods=['get'], url_path='my-lot-bookings',
472 permission_classes=[IsAuthenticated, (permissions.IsAdminUser | IsLotOperator)])
473 def my_lot_bookings(self, request):
475 queryset = self.get_queryset()
476 if request.user.is_staff:
477 queryset = queryset.filter(spot__lot_id__isnull=False)
478 else:
479 try:
480 operator_profile = request.user.operator_profile
481 operator_lot_id = operator_profile.lot_id
482 except OperatorProfile.DoesNotExist:
483 return Response({'detail': 'Користувач не є оператором (профіль не знайдено).'},
484 status=status.HTTP_403_FORBIDDEN)
486 if operator_lot_id is None:
487 return Response({'detail': 'За вами не закріплено жодного паркувального лоту.'},
488 status=status.HTTP_403_FORBIDDEN)
490 queryset = queryset.filter(spot__lot_id=operator_lot_id)
492 queryset = queryset.order_by('start_at')
494 page = self.paginate_queryset(queryset)
495 if page is not None:
496 serializer = BookingSerializer(page, many=True)
497 return self.get_paginated_response(serializer.data)
499 serializer = BookingSerializer(queryset, many=True)
500 return Response(serializer.data, status=status.HTTP_200_OK)
502 @extend_schema(
503 summary="[Operator] Cancel a booking in my lot",
504 description="Allows an operator to cancel *any* booking within their assigned lot. Requires a reason.",
505 request=OperatorBookingCancelSerializer,
506 responses={
507 200: BookingSerializer,
508 400: OpenApiResponse(ErrorSerializer, description="Booking already cancelled or completed"),
509 403: OpenApiResponse(ErrorSerializer, description="Forbidden - You do not have permission for this booking's lot"),
510 404: OpenApiResponse(ErrorSerializer, description="Booking not found"),
511 }
512 )
513 @action(detail=True, methods=['post'], url_path='cancel-operator',
514 permission_classes=[IsAuthenticated, (permissions.IsAdminUser | IsLotOperator)])
515 @transaction.atomic
516 def cancel_by_operator(self, request, pk=None):
517 try:
518 booking = self.get_object()
519 except Booking.DoesNotExist:
520 return Response({'detail': 'Booking not found.'}, status=status.HTTP_404_NOT_FOUND)
522 if booking.status == 'cancelled':
523 return Response({'detail': 'Booking is already cancelled.'}, status=status.HTTP_400_BAD_REQUEST)
525 serializer = OperatorBookingCancelSerializer(data=request.data)
526 serializer.is_valid(raise_exception=True)
527 reason = serializer.validated_data['reason']
529 refund_result = PaymentService.process_refund(booking)
530 cancellation_error = booking.check_cancellable_error()
532 if cancellation_error:
533 return Response(
534 {'detail': cancellation_error},
535 status=status.HTTP_400_BAD_REQUEST
536 )
538 booking.status = 'cancelled'
539 operator_reason = CancellationService.get_operator_cancellation_reason(
540 operator_username=request.user.username,
541 comment=reason
542 )
543 booking.cancellation_reason = operator_reason
544 booking.save(update_fields=['status', 'cancellation_reason'])
546 BookingNotificationService.send_cancellation_confirmation(booking)
548 return Response({
549 'detail': 'Booking successfully cancelled by operator.',
550 'booking_id': booking.id,
551 'reason': operator_reason,
552 'refund_status': refund_result.get('status', 'N/A'),
553 }, status=status.HTTP_200_OK)
556class UserViewSet(mixins.RetrieveModelMixin,
557 mixins.ListModelMixin,
558 viewsets.GenericViewSet):
559 http_method_names = ['get', 'post', 'patch', 'head', 'options', 'delete']
560 queryset = User.objects.all().order_by('id')
561 serializer_class = UserRegistrationSerializer
563 def get_permissions(self):
564 if self.action == 'register':
565 return [AllowAny()]
566 if self.action == 'me':
567 return [IsAuthenticated()]
568 return [IsAuthenticated(), permissions.IsAdminUser()]
570 def get_serializer_class(self):
571 if self.action == 'register':
572 return UserRegistrationSerializer
573 if self.action == 'me':
574 if self.request.method == 'PATCH':
575 return UserProfileUpdateSerializer
576 return UserSerializer
577 if self.action == 'make_operator':
578 return OperatorAssignSerializer
579 return UserSerializer
581 @extend_schema(
582 summary="Register a new user",
583 description="Creates a new user account. After registration, the user can log in.",
584 request=UserRegistrationSerializer,
585 responses={
586 201: OpenApiResponse(UserSerializer, description="User successfully created"),
587 400: OpenApiResponse(ErrorSerializer, description="Validation error (e.g., username exists, weak password)"),
588 }
589 )
590 @action(detail=False, methods=['post'], url_path='register')
591 def register(self, request):
592 serializer = self.get_serializer(data=request.data)
593 serializer.is_valid(raise_exception=True)
594 user = serializer.save()
595 response_serializer = UserSerializer(user)
596 return Response(response_serializer.data, status=status.HTTP_201_CREATED)
598 @extend_schema(
599 summary="Get/Update current user profile",
600 description="GET: Returns current user details (and checks if they are an operator). "
601 "PATCH: Updates profile data (first_name, last_name).",
602 request=UserProfileUpdateSerializer,
603 responses={
604 200: UserSerializer,
605 401: OpenApiResponse(ErrorSerializer, description="Authentication required"),
606 }
607 )
608 @action(detail=False, methods=['get', 'patch'], url_path='me')
609 def me(self, request):
610 if request.method == 'GET':
611 user = User.objects.select_related('operator_profile').get(pk=request.user.pk)
612 return Response(self.get_serializer(user).data)
614 elif request.method == 'PATCH':
615 user = request.user
616 serializer = self.get_serializer(user, data=request.data, partial=True)
617 serializer.is_valid(raise_exception=True)
618 serializer.save()
619 user.refresh_from_db()
620 user_data = User.objects.select_related('operator_profile').get(pk=user.pk)
621 return Response(UserSerializer(user_data).data)
623 return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
625 @extend_schema(
626 summary="[Admin] Get any user details",
627 description="Returns details for a specific user by ID.",
628 responses={ 200: UserSerializer, 404: ErrorSerializer, 403: ErrorSerializer }
629 )
630 def retrieve(self, request, *args, **kwargs):
631 return super().retrieve(request, *args, **kwargs)
633 @extend_schema(
634 summary="[Admin] Make a user an Admin",
635 description="Grants staff privileges to a user (user.is_staff = True).",
636 responses={ 200: UserSerializer, 404: ErrorSerializer, 403: ErrorSerializer }
637 )
638 @action(detail=True, methods=['post'], url_path='make-admin')
639 @transaction.atomic
640 def make_admin(self, request, pk=None):
641 user = self.get_object()
642 if user.is_superuser:
643 return Response({'detail': 'Cannot modify superuser status.'}, status=status.HTTP_403_FORBIDDEN)
645 user.is_staff = True
646 user.save(update_fields=['is_staff'])
648 profile = getattr(user, 'operator_profile', None)
649 if profile:
650 profile.delete()
652 user.refresh_from_db()
653 user_data = User.objects.select_related('operator_profile').get(pk=user.pk)
654 return Response(UserSerializer(user_data).data, status=status.HTTP_200_OK)
656 @extend_schema(
657 summary="[Admin] Remove Admin role",
658 description="Revokes staff privileges from a user (user.is_staff = False).",
659 responses={ 200: UserSerializer, 404: ErrorSerializer, 403: ErrorSerializer }
660 )
661 @action(detail=True, methods=['delete'], url_path='remove-admin')
662 @transaction.atomic
663 def remove_admin(self, request, pk=None):
664 user = self.get_object()
665 if user.is_superuser:
666 return Response({'detail': 'Cannot modify superuser status.'}, status=status.HTTP_403_FORBIDDEN)
668 user.is_staff = False
669 user.save(update_fields=['is_staff'])
670 user_data = User.objects.select_related('operator_profile').get(pk=user.pk)
671 return Response(UserSerializer(user_data).data, status=status.HTTP_200_OK)
673 @extend_schema(
674 summary="[Admin] Assign a user as Lot Operator",
675 description="Assigns a user to manage a specific lot. Replaces existing assignment.",
676 request=OperatorAssignSerializer,
677 responses={ 201: UserSerializer, 400: ErrorSerializer, 404: ErrorSerializer }
678 )
679 @action(detail=True, methods=['post'], url_path='make-operator')
680 @transaction.atomic
681 def make_operator(self, request, pk=None):
683 user = self.get_object()
684 if user.is_staff or user.is_superuser:
685 return Response({'detail': 'Admins or Superusers cannot be assigned as operators.'}, status=status.HTTP_400_BAD_REQUEST)
687 serializer = self.get_serializer(data=request.data)
688 serializer.is_valid(raise_exception=True)
689 lot = get_object_or_404(ParkingLot, pk=serializer.validated_data['lot_id'])
691 _, _ = OperatorProfile.objects.update_or_create(
692 user=user,
693 defaults={'lot': lot}
694 )
696 user_data = User.objects.select_related('operator_profile').get(pk=user.pk)
697 return Response(UserSerializer(user_data).data, status=status.HTTP_201_CREATED)
699 @extend_schema(
700 summary="[Admin] Remove Operator role",
701 description="Removes the operator profile from a user, revoking their operator access.",
702 responses={ 204: OpenApiResponse(description="Role removed"), 404: ErrorSerializer }
703 )
704 @action(detail=True, methods=['delete'], url_path='remove-operator')
705 @transaction.atomic
706 def remove_operator(self, request, pk=None):
707 user = self.get_object()
708 profile = getattr(user, 'operator_profile', None)
710 if profile:
711 profile.delete()
712 return Response(status=status.HTTP_204_NO_CONTENT)
713 else:
714 return Response({'detail': 'User is not an operator.'}, status=status.HTTP_404_NOT_FOUND)