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

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 

27 

28 

29class ParkingLotViewSet(viewsets.ModelViewSet): 

30 queryset = ParkingLot.objects.all().prefetch_related("spots").order_by('name') 

31 

32 def get_serializer_class(self): 

33 if self.action == 'list': 

34 return ParkingLotSerializer 

35 return ParkingLotDetailSerializer 

36 

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() 

43 

44 @extend_schema(summary="[Admin] Create a new lot") 

45 def create(self, request, *args, **kwargs): 

46 return super().create(request, *args, **kwargs) 

47 

48 @extend_schema(summary="[Admin] Update a lot") 

49 def update(self, request, *args, **kwargs): 

50 return super().update(request, *args, **kwargs) 

51 

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) 

55 

56 @extend_schema(summary="[Admin] Delete a lot") 

57 def destroy(self, request, *args, **kwargs): 

58 return super().destroy(request, *args, **kwargs) 

59 

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() 

66 

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 ) 

72 

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 ) 

80 

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) 

90 

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) 

101 

102 

103class SpotViewSet(mixins.ListModelMixin, 

104 mixins.RetrieveModelMixin, 

105 mixins.DestroyModelMixin, 

106 viewsets.GenericViewSet): 

107 serializer_class = SpotSerializer 

108 

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') 

112 

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() 

119 

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() 

150 

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" 

158 

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) 

164 

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) 

169 

170 available_from = request.query_params.get("available_from") 

171 available_to = request.query_params.get("available_to") 

172 

173 if available_from and available_to: 

174 start = parse_datetime(available_from) 

175 end = parse_datetime(available_to) 

176 

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 ) 

182 

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) 

188 

189 qs = qs.exclude(id__in=booked_spots) 

190 

191 self.queryset = qs 

192 return super().list(request, *args, **kwargs) 

193 

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) 

204 

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) 

222 

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 ) 

230 

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."}) 

234 

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) 

238 

239 response_data = serializer.data 

240 response_data["lot_name"] = lot.name 

241 return Response(response_data, status=status.HTTP_201_CREATED) 

242 

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) 

262 

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) 

275 

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 ) 

282 

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 ) 

288 

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 ) 

296 

297 

298class BookingViewSet(mixins.ListModelMixin, 

299 mixins.RetrieveModelMixin, 

300 viewsets.GenericViewSet): 

301 serializer_class = BookingSerializer 

302 permission_classes = [IsAuthenticated] 

303 

304 def get_queryset(self): 

305 qs = Booking.objects.all().select_related("spot__lot", "user").order_by("-created_at") 

306 

307 if not self.request.user.is_authenticated: 

308 return Booking.objects.none() 

309 

310 if self.request.user.is_staff or self.action in ['my_lot_bookings', 'cancel_by_operator']: 

311 return qs 

312 

313 return qs.filter(user=self.request.user) 

314 

315 def get_object(self): 

316 obj = super().get_object() 

317 self.check_object_permissions(self.request, obj) 

318 return obj 

319 

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() 

337 

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 ) 

345 

346 self.queryset = qs 

347 return super().list(request, *args, **kwargs) 

348 

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) 

360 

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) 

387 

388 spot = ser.validated_data["spot"] 

389 start_at = ser.validated_data["start_at"] 

390 end_at = ser.validated_data["end_at"] 

391 

392 validate_booking_window(start_at, end_at) 

393 

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() 

400 

401 if conflict: 

402 return Response( 

403 {"detail": "This spot is already booked for the specified period."}, 

404 status=status.HTTP_409_CONFLICT 

405 ) 

406 

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 ) 

414 

415 payment_data = PaymentService.initiate_payment(booking) 

416 response_data = BookingSerializer(booking).data 

417 response_data["payment"] = payment_data 

418 

419 return Response(response_data, status=status.HTTP_201_CREATED) 

420 

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 ) 

446 

447 if booking.status == "cancelled": 

448 return Response( 

449 {"detail": "This booking has already been cancelled."}, 

450 status=status.HTTP_400_BAD_REQUEST 

451 ) 

452 

453 cancel_serializer = BookingCancelSerializer(data=request.data) 

454 cancel_serializer.is_valid(raise_exception=True) 

455 reason = cancel_serializer.validated_data.get("reason", "") 

456 

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) 

462 

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): 

474 

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) 

485 

486 if operator_lot_id is None: 

487 return Response({'detail': 'За вами не закріплено жодного паркувального лоту.'}, 

488 status=status.HTTP_403_FORBIDDEN) 

489 

490 queryset = queryset.filter(spot__lot_id=operator_lot_id) 

491 

492 queryset = queryset.order_by('start_at') 

493 

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) 

498 

499 serializer = BookingSerializer(queryset, many=True) 

500 return Response(serializer.data, status=status.HTTP_200_OK) 

501 

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) 

521 

522 if booking.status == 'cancelled': 

523 return Response({'detail': 'Booking is already cancelled.'}, status=status.HTTP_400_BAD_REQUEST) 

524 

525 serializer = OperatorBookingCancelSerializer(data=request.data) 

526 serializer.is_valid(raise_exception=True) 

527 reason = serializer.validated_data['reason'] 

528 

529 refund_result = PaymentService.process_refund(booking) 

530 cancellation_error = booking.check_cancellable_error() 

531 

532 if cancellation_error: 

533 return Response( 

534 {'detail': cancellation_error}, 

535 status=status.HTTP_400_BAD_REQUEST 

536 ) 

537 

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']) 

545 

546 BookingNotificationService.send_cancellation_confirmation(booking) 

547 

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) 

554 

555 

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 

562 

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()] 

569 

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 

580 

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) 

597 

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) 

613 

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) 

622 

623 return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) 

624 

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) 

632 

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) 

644 

645 user.is_staff = True 

646 user.save(update_fields=['is_staff']) 

647 

648 profile = getattr(user, 'operator_profile', None) 

649 if profile: 

650 profile.delete() 

651 

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) 

655 

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) 

667 

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) 

672 

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): 

682 

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) 

686 

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']) 

690 

691 _, _ = OperatorProfile.objects.update_or_create( 

692 user=user, 

693 defaults={'lot': lot} 

694 ) 

695 

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) 

698 

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) 

709 

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)