Coverage for travels\services\travel_service.py: 97.0%

439 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-30 20:58 +0200

1import logging 

2import random 

3import string 

4from datetime import datetime, timedelta 

5 

6from django.contrib.gis.db.models.functions import Distance 

7from django.contrib.gis.geos import Point 

8from django.contrib.gis.measure import D 

9from django.db import transaction 

10from django.db.models import (Count, DateTimeField, Exists, ExpressionWrapper, F, Max, Min, OuterRef, Q) 

11from django.template.defaultfilters import date as django_date 

12from django.utils import timezone 

13from django.utils.dateparse import parse_date, parse_datetime 

14from django.utils.timezone import is_naive, make_aware 

15 

16from travels.models import (PickUpPoints, RequestStates, RequestTravels, Travel, TravelStates, UsersDenied) 

17from travels.services.exceptions import (AlreadyRequestedError, DateChangeWithOccupiedSeatsError, EndDateRequiredError, 

18 InvalidPeriodicIntervalError, InvalidRequestStatusError, InvalidStatusTransitionError, 

19 StatusNotRequestableError, InvalidValidationCodeError, NotTravelOwnerError, 

20 PassengerAlreadyValidatedError, PassengerNotAcceptedError, PassengerNotFoundError, 

21 PassengerNotInTravelError, PeriodicDeniedUsersError, PeriodicSeatsReductionError, 

22 PickUpPointNotFoundError, RequestOwnTravelError, SeatsBelowOccupiedError, SeatsFullError, 

23 SeriesVehicleSeatsInsufficientError, TravelIsNotPunctualError, VehicleNotFoundError, VehicleSeatsInsufficientError) 

24from users.models import Notifications, UserType, Users, Vehicles 

25 

26# Logger para ir almacenando los logs 

27logger = logging.getLogger(__name__) 

28 

29# Estados de solicitud que impiden volver a solicitar plaza en un viaje 

30ACTIVE_REQUEST_STATES = ['accepted', 'pending', 'validated', 'unvalidated'] 

31 

32# Servicio con la logica de negocio del agregado Travel 

33class TravelService: 

34 """ 

35 Centraliza las reglas de negocio de los viajes y de sus entidades 

36 dependientes (RequestTravels, PickUpPoints, UsersDenied) 

37 No conoce HTTP, solo recibe datos ya validados, devuelve datos o lanza 

38 excepciones de dominio, y es el view quien las traduce a Response. 

39 """ 

40 

41 # Margen tras la hora de llegada prevista antes de dar un viaje por finalizado 

42 EXPIRATION_MARGIN = timedelta(hours=2) 

43 

44 # Ocupa un asiento del viaje 

45 @staticmethod 

46 def reserve_seat(travel_id): 

47 """ 

48 Descuenta un asiento del viaje indicado y bloquea la fila del viaje con select_for_update para que dos solicitudes 

49 aceptadas a la vez no puedan provocar overbooking. Lanza SeatsFullError si el viaje ya esta completo 

50 Devuelve el viaje actualizado 

51 """ 

52 with transaction.atomic(): 

53 travel = Travel.objects.select_for_update().get(pk=travel_id) 

54 

55 # Solo se puede ocupar si quedan espacios disponibles 

56 if travel.remaining_seats <= 0: 

57 logger.warning(f"Travel {travel.id_travel} is full, the seat cannot be reserved.") 

58 raise SeatsFullError(travel_id) 

59 

60 travel.remaining_seats -= 1 

61 travel.save() 

62 

63 logger.info(f"Seat reserved for travel {travel.id_travel}. Remaining seats updated to {travel.remaining_seats}.") 

64 return travel 

65 

66 # Devuelve un asiento al viaje 

67 @staticmethod 

68 def release_seat(travel_id): 

69 """ 

70 Suma un asiento al viaje indicado, bloquea la fila igual que reserve_seat, y aplica el guard de no superar 

71 el maximo de asientos del viaje.  

72 Devuelve la tupla (viaje, liberado), liberado es False cuando el viaje ya estaba al maximo y por tanto no  

73 habia nada que devolver, para que el view pueda decidir si notifica o no. 

74 """ 

75 with transaction.atomic(): 

76 travel = Travel.objects.select_for_update().get(pk=travel_id) 

77 

78 # Solo se aumenta si no esta al maximo 

79 if travel.remaining_seats >= travel.num_seats: 

80 logger.warning(f"Travel {travel.id_travel} is already at its maximum of {travel.num_seats} seats, no seat released.") 

81 return travel, False 

82 

83 travel.remaining_seats += 1 

84 travel.save() 

85 

86 logger.info(f"Seat released for travel {travel.id_travel}. Remaining seats updated to {travel.remaining_seats}.") 

87 return travel, True 

88 

89 # Solicita una plaza en uno o varios viajes 

90 @staticmethod 

91 def request_seat(user, travel_ids): 

92 """ 

93 Crea una solicitud pendiente por cada viaje indicado y avisa a su conductor 

94 Lanza RequestOwnTravelError si alguno de los viajes es del propio usuario, 

95 y AlreadyRequestedError si ya tiene una solicitud en alguno de ellos 

96 Devuelve el numero de solicitudes creadas 

97 """ 

98 with transaction.atomic(): 

99 # No se debe permitir que el usuario solicite una plaza para su propio viaje 

100 if Travel.objects.filter(id_travel__in=travel_ids, creation_user=user, is_deleted=False).exists(): 

101 logger.warning(f"User {user.username} tried to request their own travel {travel_ids}") 

102 raise RequestOwnTravelError() 

103 

104 # No se debe permitir solicitar plaza en un viaje en el que ya tiene una plaza aceptada o pendiente 

105 if RequestTravels.objects.filter(id_travel__in=travel_ids, user=user, status__in=ACTIVE_REQUEST_STATES, is_deleted=False).exists(): 

106 logger.warning(f"User {user.username} tried to request an already accepted travel {travel_ids}") 

107 raise AlreadyRequestedError() 

108 

109 travel_list = Travel.objects.filter(id_travel__in=travel_ids, is_deleted=False) 

110 

111 # Se saca el estado pending para asignarlo a las nuevas solicitudes 

112 pending_state = RequestStates.objects.get(code='pending') 

113 

114 requests_created = 0 

115 # Se crea la solicitud por cada uno de los viajes 

116 for travel in travel_list: 

117 RequestTravels.objects.create( 

118 id_travel=travel, 

119 user=user, 

120 status=pending_state 

121 ) 

122 requests_created += 1 

123 # Mensaje que se envia al dueño del viaje para notifiacarle que acaban de solicitar plaza en uno de sus viajes 

124 msg = f"Tienes una nueva solicitud en el viaje {travel.origin} - {travel.destination}" 

125 

126 # Se crea la notificacion 

127 Notifications.objects.create( 

128 id_user=travel.creation_user, 

129 content=msg, 

130 is_deleted=False 

131 ) 

132 

133 return requests_created 

134 

135 # Genera un codigo de validacion unico dentro del viaje 

136 @staticmethod 

137 def _generate_validation_code(travel): 

138 validation_code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) 

139 # Se asegura que el codigo sea unico 

140 while RequestTravels.objects.filter(id_travel=travel.id_travel, validation_code=validation_code, is_deleted=False).exists(): 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 validation_code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) 

142 return validation_code 

143 

144 # Transiciones permitidas en el ciclo de vida de una solicitud 

145 ALLOW_TRANSITIONS = { 

146 'pending': {'accepted', 'rejected'}, 

147 'accepted': {'validated', 'unvalidated', 'rejected'}, 

148 'validated': {'unvalidated'}, 

149 # Estados finales: de aqui no se sale 

150 'rejected': set(), 

151 'unvalidated': set(), 

152 } 

153 

154 # Estados que un cliente puede pedir por la API 

155 API_REQUESTABLE_STATES = {'accepted', 'rejected'} 

156 

157 # Cambia el estado de una solicitud aplicando sus efectos sobre el viaje 

158 @staticmethod 

159 def change_request_status(request_travel, new_status_code, from_api=True): 

160 """ 

161 Aplica la transicion de estado de una solicitud y sus efectos: 

162 

163 pending -> accepted : ocupa asiento, genera codigo y notifica 

164 pending -> rejected : solo notifica el rechazo 

165 accepted -> rejected : libera asiento y notifica la expulsion 

166 accepted -> validated / unvalidated : notifica al pasajero 

167 validated -> unvalidated : notifica al pasajero 

168 """ 

169 with transaction.atomic(): 

170 current_status = request_travel.status.code 

171 travel = request_travel.id_travel 

172 

173 try: 

174 new_status_obj = RequestStates.objects.get(code=new_status_code) 

175 except RequestStates.DoesNotExist: 

176 logger.error(f"Error Status code {new_status_code} invalid") 

177 raise InvalidRequestStatusError(new_status_code) 

178 

179 # Si no se puede pedir el estado, debe lanzar una excepcion 

180 if from_api and new_status_code not in TravelService.API_REQUESTABLE_STATES: 

181 logger.warning( 

182 f"Rejected API request for status {new_status_code} on request {request_travel.id}" 

183 ) 

184 raise StatusNotRequestableError(new_status_code) 

185 

186 # Pedir el estado que ya tiene no es un salto imposible, es no hacer nada 

187 if current_status == new_status_code: 

188 logger.info( 

189 f"Request {request_travel.id} already in status {new_status_code}, nothing to do" 

190 ) 

191 return request_travel 

192 # Si solicita un salto imposible, se lanza una excepcion 

193 if new_status_code not in TravelService.ALLOW_TRANSITIONS.get(current_status, set()): 

194 logger.warning( 

195 f"Rejected transition {current_status} -> {new_status_code} for request {request_travel.id}" 

196 ) 

197 raise InvalidStatusTransitionError(current_status, new_status_code) 

198 

199 # En caso de que se apruebe la solicitud 

200 if current_status == 'pending' and new_status_code == 'accepted': 

201 # Solo se puede aceptar si quedan espacios disponibles 

202 TravelService.reserve_seat(travel.pk) 

203 

204 # Se genera un codigo de validacion para el pasajero 

205 validation_code = TravelService._generate_validation_code(travel) 

206 request_travel.validation_code = validation_code 

207 # Se le notifica al usuario 

208 Notifications.objects.create( 

209 id_user=request_travel.user, 

210 content=f"Tu solicitud para el viaje {travel.origin} - {travel.destination} ha sido aceptada. Tu código de validación es: {validation_code}" 

211 ) 

212 

213 # Si la solicitud estaba aceptada, pero se elimina 

214 elif current_status == 'accepted' and new_status_code in ['rejected']: 

215 

216 TravelService.release_seat(travel.pk) 

217 

218 # Eliminan al pasajero, por lo que se le notifica 

219 Notifications.objects.create( 

220 id_user=request_travel.user, 

221 content=f"El creador del viaje {travel.origin} - {travel.destination} te ha eliminado del viaje." 

222 ) 

223 

224 # Si estaba pendiente y se rechaza 

225 elif current_status == 'pending' and new_status_code == 'rejected': 

226 # Se notifica al usuario de que le han rechazado la solicitud 

227 Notifications.objects.create( 

228 id_user=request_travel.user, 

229 content=f"Tu solicitud para el viaje {travel.origin} - {travel.destination} ha sido rechazada." 

230 ) 

231 

232 request_travel.status = new_status_obj 

233 request_travel.save() 

234 return request_travel 

235 

236 # Efectos de cancelar una solicitud antes de borrarla 

237 @staticmethod 

238 def cancel_request(request_travel): 

239 """ 

240 Si la solicitud estaba aceptada, libera el asiento y avisa al conductor 

241 Devuelve True si se llego a liberar un asiento 

242 """ 

243 with transaction.atomic(): 

244 if request_travel.status.code != 'accepted': 

245 return False 

246 

247 # Si la solicitud estaba aceptada, se debe liberar un asiento en el viaje 

248 travel, released = TravelService.release_seat(request_travel.id_travel_id) 

249 if released: # Solo se avisa al conductor si realmente se ha liberado 249 ↛ 255line 249 didn't jump to line 255 because the condition on line 249 was always true

250 logger.info(f"Seat released for travel {travel.id_travel} due to deletion of accepted request.") 

251 Notifications.objects.create( 

252 id_user=travel.creation_user, 

253 content=f"Un pasajero ha cancelado su solicitud para el viaje {travel.origin} - {travel.destination}. Se ha liberado un asiento." 

254 ) 

255 return released 

256 

257 # Expulsa a un pasajero del viaje y libera su asiento 

258 @staticmethod 

259 def remove_passenger(travel, passenger_username): 

260 """ 

261 Rechaza la solicitud aceptada del pasajero, libera su asiento y le notifica 

262 Lanza PassengerNotFoundError si no existe el usuario y PassengerNotInTravelError si no tenia una solicitud aceptada 

263 Devuelve la tupla (viaje actualizado, pasajero) 

264 """ 

265 passenger = Users.objects.filter(username=passenger_username).first() 

266 # Si no existe el pasajero con ese nombre de usuario, se devuelve error 

267 if not passenger: 

268 logger.error(f"Passenger with username {passenger_username} not found") 

269 raise PassengerNotFoundError(passenger_username) 

270 

271 with transaction.atomic(): 

272 # Se saca la solicitud del pasajero para ese viaje 

273 try: 

274 request_to_remove = RequestTravels.objects.get(id_travel=travel, user__id=passenger.id, status__code='accepted') 

275 except RequestTravels.DoesNotExist: 

276 raise PassengerNotInTravelError(passenger_username) 

277 

278 request_to_remove.status = RequestStates.objects.get(code='rejected') 

279 request_to_remove.save() 

280 

281 # El asiento se libera con el metodo que lo centraliza 

282 travel, _ = TravelService.release_seat(travel.pk) 

283 

284 # Se notifica al pasajero que ha sido eliminado del viaje 

285 date = travel.travel_date.strftime("%d/%m/%Y %H:%M") 

286 Notifications.objects.create( 

287 id_user=passenger, 

288 content=f"Has sido eliminado del viaje {travel.origin} - {travel.destination} del dia {date}." 

289 ) 

290 logger.info(f"Passenger with ID {passenger} removed from travel {travel.id_travel}. Remaining seats updated to {travel.remaining_seats}. Notification sent.") 

291 

292 return travel, passenger 

293 

294 # Valida a un pasajero mediante su codigo de validacion 

295 @staticmethod 

296 def validate_passenger(travel, code): 

297 """ 

298 Marca como validated la solicitud del viaje que tenga ese codigo 

299 Lanza InvalidValidationCodeError, PassengerAlreadyValidatedError o PassengerNotAcceptedError segun el caso. 

300 Devuelve la solicitud validada 

301 """ 

302 # Se busca la solicitud con ese codigo de validacion que pertenezca a este viaje 

303 try: 

304 request_travel = RequestTravels.objects.get( 

305 id_travel=travel, 

306 validation_code=code, 

307 is_deleted=False 

308 ) 

309 except RequestTravels.DoesNotExist: 

310 logger.warning(f"Validation code {code} not found for travel {travel.id_travel}") 

311 raise InvalidValidationCodeError(code) 

312 

313 if request_travel.status.code == 'validated': 

314 # Si ya esta validado, se informa al usuario que ya ha sido validado 

315 logger.warning(f"Passenger {request_travel.user.username} already validated for travel {travel.id_travel}") 

316 raise PassengerAlreadyValidatedError(request_travel.user.username) 

317 

318 # Si la solicitud no esta aceptada, no se puede validar 

319 if request_travel.status.code != 'accepted': 

320 logger.warning(f"Passenger {request_travel.user.username} has status {request_travel.status.code} and cannot be validated for travel {travel.id_travel}") 

321 raise PassengerNotAcceptedError(request_travel.user.username) 

322 

323 # Se actualiza el estado a validado 

324 request_travel.status = RequestStates.objects.get(code='validated') 

325 request_travel.save() 

326 

327 logger.info(f"Passenger {request_travel.user.username} validated for travel {travel.id_travel} with code {code}") 

328 return request_travel 

329 

330 # Finaliza el viaje y actualiza el estado de sus pasajeros 

331 @staticmethod 

332 def finish_travel(travel): 

333 """ 

334 Pasa el viaje a finalizado, marca como unvalidated a los pasajeros que no 

335 se validaron y como validated a los que si, y los notifica a ambos. 

336 Devuelve el numero de pasajeros marcados como no validados. 

337 """ 

338 parsed_date = django_date(travel.travel_date, r"j \d\e F \d\e Y") 

339 travel_label = f"El viaje de {travel.origin} a {travel.destination} del día {parsed_date}" 

340 

341 with transaction.atomic(): 

342 # Se cambia el estado del viaje a finalizado 

343 travel.state = TravelStates.objects.get(code='fnd') 

344 travel.save() 

345 

346 # Se buscan las solicitudes aceptadas (no validadas) y se marcan como unvalidated 

347 unvalidated_state = RequestStates.objects.get(code='unvalidated') 

348 unvalidated_requests = RequestTravels.objects.filter( 

349 id_travel=travel, 

350 status__code='accepted', 

351 is_deleted=False 

352 ) 

353 

354 unvalidated_count = 0 

355 for req in unvalidated_requests: 

356 req.status = unvalidated_state 

357 req.save() 

358 unvalidated_count += 1 

359 # Se notifica al pasajero que no fue validado 

360 Notifications.objects.create( 

361 id_user=req.user, 

362 content=f"{travel_label} ha finalizado, NO has sido validado en él." 

363 ) 

364 

365 logger.info(f"Travel {travel.id_travel} finished. {unvalidated_count} passengers marked as unvalidated.") 

366 

367 # Se buscan las solicitudes validadas para enviarles la notificacion de que su viaje ha terminado y pueden puntuar al conductor 

368 validated_requests = RequestTravels.objects.filter( 

369 id_travel=travel, 

370 status__code='validated', 

371 is_deleted=False 

372 ) 

373 

374 for req in validated_requests: 

375 # Se notifica a los pasajeros validados 

376 Notifications.objects.create( 

377 id_user=req.user, 

378 content=f"{travel_label} ha finalizado. Puedes puntuar al conductor." 

379 ) 

380 

381 logger.info(f"Notifications sent to {validated_requests.count()} validated passengers for travel {travel.id_travel}.") 

382 return unvalidated_count 

383 

384 # Viajes cuyo plazo para validar pasajeros ya ha vencido 

385 @staticmethod 

386 def find_expired_travels(now=None): 

387 """ 

388 Viajes activos cuya hora de llegada prevista (salida + duracion) lleva mas de EXPIRATION_MARGIN pasada 

389 """ 

390 now = now or timezone.now() 

391 

392 return Travel.objects.annotate( 

393 expiration_date=ExpressionWrapper( 

394 F('travel_date') + (timedelta(minutes=1) * F('duration_minutes')) + TravelService.EXPIRATION_MARGIN, 

395 output_field=DateTimeField() 

396 ) 

397 ).filter( 

398 state='active', 

399 expiration_date__lte=now, 

400 is_deleted=False 

401 ) 

402 

403 # Cierre automatico de los viajes caducados (lo usa el comando programado) 

404 @staticmethod 

405 def finish_expired_travels(now=None): 

406 """ 

407 Finaliza todos los viajes caducados aplicando la misma regla que cuando el conductor finaliza a mano 

408 Devuelve la tupla (viajes finalizados, pasajeros marcados como no validados). 

409 """ 

410 expired_travels = list(TravelService.find_expired_travels(now)) 

411 

412 finished_count = 0 

413 unvalidated_count = 0 

414 for travel in expired_travels: 

415 unvalidated_count += TravelService.finish_travel(travel) 

416 finished_count += 1 

417 

418 return finished_count, unvalidated_count 

419 

420 # Efectos de eliminar un viaje sobre sus solicitudes 

421 @staticmethod 

422 def delete_travel(travel): 

423 """ 

424 Notifica la cancelacion a todos los solicitantes y marca como borradas sus 

425 solicitudes, solo actua si el viaje sigue activo 

426 Devuelve el numero de notificaciones enviadas 

427 """ 

428 # Solo se borran las solicitudes si el viaje esta activo, si ya ha pasado, no se borran 

429 if travel.state.code != 'active': 

430 return 0 

431 

432 # Se sacan las solicitudes del viaje 

433 request_travels = travel.requested_travels.filter(is_deleted=False) 

434 

435 notifications = [] 

436 # Se crea el mensaje generico que se le envia a cada usuario 

437 parsed_date = django_date(travel.travel_date, r"j \d\e F \d\e Y") 

438 msg = f"El viaje de {travel.origin} a {travel.destination} del día {parsed_date} ha sido cancelado." 

439 

440 # Se guardan las notificaciones para insertarlas en bbdd 

441 for rt in request_travels: # Se notifica a todos los usuarios con una solicitud, aunque estuviera pendiente 

442 notifications.append( 

443 Notifications( 

444 id_user=rt.user, 

445 content=msg 

446 ) 

447 ) 

448 # Se insertan las notificaciones en bbdd 

449 if notifications: 449 ↛ 451line 449 didn't jump to line 451 because the condition on line 449 was always true

450 Notifications.objects.bulk_create(notifications) 

451 logger.info(f"Sent {len(notifications)} notifications for the deleted travel {travel.id_travel}.") 

452 

453 logger.info(f"Soft-deleting requests associated with travel {travel.id_travel}.") 

454 travel.requested_travels.filter(is_deleted=False).update( 

455 is_deleted=True, 

456 deleted_at=timezone.now() 

457 ) 

458 

459 return len(notifications) 

460 

461 # Modifica los datos de un viaje 

462 @staticmethod 

463 def edit_travel(travel, data): 

464 """ 

465 Aplica la edicion de un viaje. El tratamiento es distinto segun el viaje 

466 sea periodico o puntual, asi que se delega en los dos metodos privados 

467 """ 

468 with transaction.atomic(): 

469 seats = data.get('seats') 

470 

471 if seats and seats < travel.num_seats and travel.is_periodic: 

472 # No se puede reducir el numero de asientos si el viaje es periodico 

473 raise PeriodicSeatsReductionError() 

474 

475 # Se sacan el numero de asientos ocupados 

476 if seats and seats != travel.num_seats: 

477 occupied_seats = travel.num_seats - travel.remaining_seats 

478 travel.num_seats = seats 

479 if occupied_seats > seats: # Si intenta poner menos asientos que los ocupados, se devuelve error 

480 raise SeatsBelowOccupiedError(seats, occupied_seats) 

481 # Se actualizan los nuevos asientos 

482 travel.remaining_seats = seats - occupied_seats 

483 travel.save() 

484 

485 # Se debe tratar distinto si el viaje era periodico que si es puntual 

486 if travel.is_periodic: 

487 TravelService._edit_periodic_travel(travel, data) 

488 else: 

489 TravelService._edit_punctual_travel(travel, data) 

490 

491 return travel 

492 

493 # Edicion de un viaje que era periodico 

494 @staticmethod 

495 def _edit_periodic_travel(travel, data): 

496 only_this_travel = data.get('only_this_travel', False) 

497 users_deny = data.get('users_deny', []) 

498 vehicle_id = data.get('vehicle_id') 

499 is_periodic = data.get('is_periodic') 

500 

501 # Se saca el id del padre, si el es el padre, es su propio id 

502 father_travel_id = travel.id_origin_travel.id_travel if travel.id_origin_travel else travel.id_travel 

503 

504 if only_this_travel: 

505 # Solo aplica los cambios al viaje actual 

506 travels_to_update = Travel.objects.filter(pk=travel.pk) 

507 else: 

508 # Aplica al viaje actual y a todos los futuros de su misma serie periodica 

509 travels_to_update = Travel.objects.filter( 

510 Q(id_origin_travel=father_travel_id) | Q(pk=father_travel_id), 

511 travel_date__gte=travel.travel_date, 

512 is_deleted=False 

513 ) 

514 

515 # En los viajes periodicos, no se puede cambiar los usuarios denegados 

516 if users_deny and users_deny != list(travel.users_denied.filter(is_deleted=False).values_list('user_type__code', flat=True)): 

517 raise PeriodicDeniedUsersError() 

518 

519 # Si se ha cambiado de vehiculo, se busca el nuevo y se añade a los viajes futuros 

520 if vehicle_id and vehicle_id != str(travel.vehicle.id_vehicle): 

521 vehicle = Vehicles.objects.filter(id_vehicle=vehicle_id, is_deleted=False) 

522 if not vehicle: # Si no se encuentra se lanza error 

523 raise VehicleNotFoundError() 

524 

525 # Se comprueba que no se pueda cambiar a un vehiculo con menos asientos que el actual 

526 if vehicle.first().seats - 1 < travel.num_seats: 

527 raise VehicleSeatsInsufficientError() 

528 

529 travel.vehicle = vehicle.first() 

530 travel.save() 

531 

532 if not only_this_travel: 

533 has_invalid_travels = travels_to_update.filter(num_seats__gt=vehicle.first().seats - 1).exists() 

534 if has_invalid_travels: 

535 raise SeriesVehicleSeatsInsufficientError() 

536 

537 # Se actualiza el vehiculo en cada uno de los viajes hijos 

538 travels_to_update.update(vehicle=travel.vehicle) 

539 # Se notifica a los pasajeros de que ha cambiado su vehiculo 

540 TravelService._notify_vehicle_change( 

541 RequestTravels.objects.filter( 

542 id_travel__in=travels_to_update.values_list('id_travel', flat=True), 

543 status='accepted', 

544 is_deleted=False 

545 ) 

546 ) 

547 logger.info(f"Changed vehicle for travel {travel.id_travel} and futures. Notified the passengers of the change.") 

548 

549 # Se desea cambiarlo a puntual 

550 if is_periodic is False: 

551 travels_to_update.update(is_periodic=False, periodic_interval=None, end_periodic_date=None, id_origin_travel=None) 

552 logger.info(f"Changed travels {travels_to_update.values_list('id_travel', flat=True)} to punctual.") 

553 

554 # Edicion de un viaje que era puntual 

555 @staticmethod 

556 def _edit_punctual_travel(travel, data): 

557 # Hay que checkear si vienen las coordenadoas en el JSON, (si no ha cambiado de lugares no vienen) 

558 orig_lat = data.get('origin_lat') 

559 orig_lng = data.get('origin_lng') 

560 dest_lat = data.get('destination_lat') 

561 dest_lng = data.get('destination_lng') 

562 

563 # El origen y el destino siempre vienen, aunque no cambien 

564 origin = data.get('origin') 

565 destination = data.get('destination') 

566 

567 date_raw = data.get('travel_date') 

568 if date_raw: 

569 _parsed = parse_datetime(date_raw) 

570 date = make_aware(_parsed) if (_parsed and is_naive(_parsed)) else _parsed 

571 else: 

572 date = None 

573 

574 users_deny = data.get('users_deny', []) 

575 vehicle_id = data.get('vehicle_id') 

576 duration = data.get('duration') 

577 is_periodic = data.get('is_periodic') 

578 

579 # Cambios de origen y destino (junto con sus coordenadas) Esto solo se permite en viajes puntuales 

580 if (origin and origin != travel.origin) or (destination and destination != travel.destination): 

581 travel.origin = origin if origin else travel.origin 

582 travel.destination = destination if destination else travel.destination 

583 

584 if orig_lat and orig_lng: 584 ↛ 587line 584 didn't jump to line 587 because the condition on line 584 was always true

585 travel.origin_point = Point(float(orig_lng), float(orig_lat), srid=4326) 

586 

587 if dest_lat and dest_lng: 587 ↛ 590line 587 didn't jump to line 590 because the condition on line 587 was always true

588 travel.destination_point = Point(float(dest_lng), float(dest_lat), srid=4326) 

589 # Se cambia la fecha del viaje 

590 travel.travel_date = date if date else travel.travel_date 

591 travel.save() 

592 

593 if duration and duration != travel.duration_minutes: 

594 travel.duration_minutes = duration 

595 travel.save() 

596 

597 if vehicle_id and vehicle_id != str(travel.vehicle.id_vehicle): 

598 # Se cambia el vehiculo y se notifica a los pasajeros 

599 vehicle = Vehicles.objects.filter(id_vehicle=vehicle_id, is_deleted=False) 

600 if not vehicle: 

601 raise VehicleNotFoundError() 

602 travel.vehicle = vehicle.first() 

603 travel.save() 

604 TravelService._notify_vehicle_change( 

605 RequestTravels.objects.filter(id_travel=travel.id_travel, status='accepted', is_deleted=False) 

606 ) 

607 

608 # Se actualiza la fecha del viaje si no hay plazas ocupadas. 

609 if date is not None and date != travel.travel_date: 

610 if travel.remaining_seats < travel.num_seats: 

611 raise DateChangeWithOccupiedSeatsError() 

612 travel.travel_date = date 

613 travel.save() 

614 

615 # Si se desea cambiar los usuarios denegados, se borran los antiguos y se crean nuevos 

616 if users_deny: 

617 # Se busca los antiguos y se borran 

618 users_deny_objs = UsersDenied.objects.filter(id_travel=travel, is_deleted=False) 

619 for ud in users_deny_objs: 619 ↛ 620line 619 didn't jump to line 620 because the loop on line 619 never started

620 ud.is_deleted = True 

621 ud.deleted_at = timezone.now() 

622 ud.save() 

623 # Se insertan los nuevos 

624 for ud in users_deny: 

625 UsersDenied.objects.create( 

626 id_travel=travel, 

627 user_type=UserType.objects.filter(code=ud).first() 

628 ) 

629 

630 # De puntual a periódico 

631 if is_periodic: 

632 TravelService._make_periodic(travel, data) 

633 

634 # Convierte un viaje puntual en periodico y genera sus viajes hijos 

635 @staticmethod 

636 def _make_periodic(travel, data): 

637 interval = data.get('periodic_interval') # Dias entre los viajes 

638 interval_days = int(interval) if interval else 0 

639 end_date_str = data.get('end_periodic_date') 

640 

641 # Si uno de los parametros no viene se devuelve error 

642 if not end_date_str: 

643 logger.error("End date is required to change to periodic") 

644 raise EndDateRequiredError() 

645 

646 if not interval_days or interval_days <= 0 or interval_days > 31: 

647 logger.error("Interval days is required and must be between 1 and 31") 

648 raise InvalidPeriodicIntervalError() 

649 

650 end_date = parse_datetime(end_date_str) or parse_date(end_date_str) 

651 # Si es un datetime, extraemos solo el date para que el bucle while no falle 

652 if hasattr(end_date, 'date'): 652 ↛ 656line 652 didn't jump to line 656 because the condition on line 652 was always true

653 end_date = end_date.date() 

654 

655 # Cambiar el viaje a periódico 

656 travel.is_periodic = True 

657 travel.periodic_interval = interval_days 

658 travel.end_periodic_date = end_date 

659 travel.save() 

660 

661 active_state = TravelStates.objects.get(code='active') 

662 

663 # Generar las fechas para los nuevos viajes hijos 

664 current_date = travel.travel_date + timedelta(days=interval_days) 

665 new_travels = [] 

666 

667 while current_date.date() <= end_date: 

668 if timezone.is_naive(current_date): 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true

669 current_date = timezone.make_aware(current_date) 

670 # Se crean los hijos copiando al padre 

671 new_travels.append(Travel( 

672 id_origin_travel=travel, 

673 travel_date=current_date, 

674 origin=travel.origin, 

675 destination=travel.destination, 

676 origin_point=travel.origin_point, 

677 duration_minutes=travel.duration_minutes, 

678 destination_point=travel.destination_point, 

679 num_seats=travel.num_seats, 

680 remaining_seats=travel.num_seats, 

681 creation_user=travel.creation_user, 

682 vehicle=travel.vehicle, 

683 state=active_state, 

684 is_periodic=True, 

685 periodic_interval=travel.periodic_interval, 

686 end_periodic_date=travel.end_periodic_date 

687 )) 

688 current_date += timedelta(days=interval_days) 

689 

690 # Se guardan los viajes hijos 

691 created_travels = Travel.objects.bulk_create(new_travels) 

692 

693 # Se copian los PickUpPoints y UsersDenied a los nuevos hijos generados 

694 parent_pickups = PickUpPoints.objects.filter(id_travel=travel, is_deleted=False) 

695 parent_denied = UsersDenied.objects.filter(id_travel=travel, is_deleted=False) 

696 

697 child_pickups = [] 

698 child_denied = [] 

699 

700 for ct in created_travels: 

701 for pp in parent_pickups: 

702 child_pickups.append(PickUpPoints( 

703 id_travel=ct, 

704 direction=pp.direction, 

705 date=pp.date, 

706 order_in_travel=pp.order_in_travel, 

707 point=pp.point 

708 )) 

709 

710 for du in parent_denied: 710 ↛ 711line 710 didn't jump to line 711 because the loop on line 710 never started

711 child_denied.append(UsersDenied( 

712 id_travel=ct, 

713 user_type=du.user_type 

714 )) 

715 

716 if child_pickups: 716 ↛ 718line 716 didn't jump to line 718 because the condition on line 716 was always true

717 PickUpPoints.objects.bulk_create(child_pickups) 

718 if child_denied: 718 ↛ 719line 718 didn't jump to line 719 because the condition on line 718 was never true

719 UsersDenied.objects.bulk_create(child_denied) 

720 

721 logger.info(f"Changed travel {travel.id_travel} to periodic. Created {len(created_travels)} new travels.") 

722 

723 # Avisa a los pasajeros aceptados de que su viaje ha cambiado de vehiculo 

724 @staticmethod 

725 def _notify_vehicle_change(request_travels): 

726 for r in request_travels: 

727 Notifications.objects.create( 

728 id_user=r.user, 

729 content=f"Se ha cambiado de vehiculo en el viaje {r.id_travel.origin} - {r.id_travel.destination} del dia {r.id_travel.travel_date.strftime('%d/%m/%Y')} revisa el viaje para ver los cambios" 

730 ) 

731 

732 # Sustituye los puntos de recogida de un viaje puntual 

733 @staticmethod 

734 def change_pickup_points(travel, pickup_points): 

735 """ 

736 Marca como borrados los puntos de recogida actuales y crea los nuevos. 

737 Lanza TravelIsNotPunctualError en viajes periodicos 

738 """ 

739 with transaction.atomic(): 

740 if travel.is_periodic: # En un viaje periodico no se pueden modificar los puntos de recogida 

741 logger.error(f"Error changing pickup points for periodic travel {travel.id_travel}.") 

742 raise TravelIsNotPunctualError() 

743 

744 # Se buscan los puntos de recogida antiguos y se marcan como borrados 

745 PickUpPoints.objects.filter(id_travel=travel, is_deleted=False).update(is_deleted=True, deleted_at=timezone.now()) 

746 # Se crean los nuevos puntos de recogida 

747 for pp in pickup_points: 

748 PickUpPoints.objects.create( 

749 id_travel=travel, 

750 direction=pp.get('direction'), 

751 date=pp.get('date'), 

752 order_in_travel=pp.get('order_in_travel'), 

753 point=Point(float(pp.get('lng')), float(pp.get('lat')), srid=4326) 

754 ) 

755 logger.info(f"Changed pickup points for travel {travel.id_travel}.") 

756 

757 # Marca un punto de recogida como alcanzado 

758 @staticmethod 

759 def reach_pickup_point(travel, point_id): 

760 """ 

761 Marca como alcanzado el punto de recogida indicado del viaje. 

762 Lanza PickUpPointNotFoundError si no pertenece a ese viaje. 

763 """ 

764 try: 

765 point = PickUpPoints.objects.get(id_point=point_id, id_travel=travel) 

766 except (PickUpPoints.DoesNotExist, ValueError): 

767 raise PickUpPointNotFoundError(point_id) 

768 

769 point.is_reached = True 

770 point.save() 

771 return point 

772 

773 # Comprueba que todos los viajes indicados los haya creado el usuario 

774 @staticmethod 

775 def assert_travels_belong_to(user, travels): 

776 """ 

777 Se usa antes de crear o modificar lo que dependa de un viaje (paradas, tipos de usuario denegados) 

778 Lanza NotTravelOwnerError si alguno de los viajes no pertenece al usuario 

779 """ 

780 for travel in travels: 

781 if travel is None: 

782 continue 

783 if travel.creation_user_id != user.pk: 

784 logger.warning( 

785 f"User {user.pk} tried to modify data of travel {travel.pk}, which is not theirs." 

786 ) 

787 raise NotTravelOwnerError(travel.pk) 

788 

789 

790 # Aplica todos los filtros de busqueda de viajes 

791 @staticmethod 

792 def search_travels(queryset, user, data): 

793 """ 

794 Construye la consulta de busqueda de viajes a partir de los filtros 

795 recibidos: origen/destino con radio, fechas, preferencias del conductor,  

796 distintivo ambiental, tipo de viaje y tipos de usuario denegados. 

797 Recibe el queryset base del view (ya sin borrados) y devuelve el queryset filtrado y ordenado, sin paginar 

798 """ 

799 # Ordenacion (Por defecto recent) 

800 sort_by = data.get('sort_by', 'recent') 

801 

802 # Se sacan las coordenadas y radios, si no vienen se pone 0 para que no se filtre 

803 orig_lat = data.get('origin', {}).get('lat', 0) 

804 orig_lng = data.get('origin', {}).get('lng', 0) 

805 dest_lat = data.get('destination', {}).get('lat', 0) 

806 dest_lng = data.get('destination', {}).get('lng', 0) 

807 

808 # Se sacan los radios de busqueda, por defecto se pone a 10km para que salgan algunos resultados relevantes 

809 rad_orig_raw = float(data.get('radius_origin_km', 0.0)) 

810 rad_orig = rad_orig_raw if rad_orig_raw > 0 else 10.0 

811 

812 rad_dest_raw = float(data.get('radius_dest_km', 0.0)) 

813 rad_dest = rad_dest_raw if rad_dest_raw > 0 else 10.0 

814 

815 # Fechas y sus parseos para poder filtrar por ellas 

816 date_from_raw = data.get('date_from') 

817 date_until_raw = data.get('date_until') 

818 

819 date_from = None 

820 date_until = None 

821 

822 if date_from_raw: 

823 parsed_from = parse_datetime(date_from_raw) 

824 if parsed_from: 

825 date_from = make_aware(parsed_from) if is_naive(parsed_from) else parsed_from 

826 

827 if date_until_raw: 

828 naive_until = datetime.strptime(date_until_raw[:10], '%Y-%m-%d') + timedelta(days=1) 

829 date_until = make_aware(naive_until) 

830 

831 # preferencias, tipo de viaje, usuarios denegados y distintivo ambiental 

832 preferences = data.get('preferences', []) 

833 env_sticker = data.get('env_sticker') 

834 travel_type = data.get('travel_type') 

835 users_deny = data.get('users_deny', []) 

836 

837 logger.debug(f"Received search parameters: Origin({orig_lat}, {orig_lng}, {rad_orig}km), " 

838 f"Destination({dest_lat}, {dest_lng}, {rad_dest}km), " 

839 f"Preferences({preferences}), EnvSticker({env_sticker}), TravelType({travel_type}), " 

840 f"DateFrom({data.get('date_from')}), DateUntil({data.get('date_until')})") 

841 

842 # Crear los puntos espaciales 

843 orig_pt = Point(orig_lng, orig_lat, srid=4326) 

844 dest_pt = Point(dest_lng, dest_lat, srid=4326) 

845 

846 has_origin = orig_lat != 0 or orig_lng != 0 

847 has_dest = dest_lat != 0 or dest_lng != 0 

848 

849 # Se sacan los viajes que no son del usuario, que tengan asientos disponibles y esten activos 

850 travels = queryset.exclude(creation_user=user).exclude(remaining_seats__lt=1).filter(state='active') 

851 

852 if has_origin and has_dest: 

853 # Anotamos el orden usando los radios 

854 travels = travels.annotate( 

855 min_orig_stop_order=Min( 

856 'pickup_points__order_in_travel', 

857 filter=Q(pickup_points__point__distance_lte=(orig_pt, D(km=rad_orig))) & Q(pickup_points__is_deleted=False) 

858 ), 

859 max_dest_stop_order=Max( 

860 'pickup_points__order_in_travel', 

861 filter=Q(pickup_points__point__distance_lte=(dest_pt, D(km=rad_dest))) & Q(pickup_points__is_deleted=False) 

862 ) 

863 ) 

864 

865 # El origen principal cuadra 

866 cond_main_origin = Q(origin_point__distance_lte=(orig_pt, D(km=rad_orig))) & ( 

867 Q(destination_point__distance_lte=(dest_pt, D(km=rad_dest))) | 

868 Q(max_dest_stop_order__isnull=False) 

869 ) 

870 

871 # El destino principal cuadra 

872 cond_main_dest = Q(destination_point__distance_lte=(dest_pt, D(km=rad_dest))) & ( 

873 Q(origin_point__distance_lte=(orig_pt, D(km=rad_orig))) | 

874 Q(min_orig_stop_order__isnull=False) 

875 ) 

876 

877 # Coincide con las paradas intermedias 

878 cond_stops = ( 

879 Q(min_orig_stop_order__isnull=False) & 

880 Q(max_dest_stop_order__isnull=False) & 

881 Q(min_orig_stop_order__lt=F('max_dest_stop_order')) 

882 ) 

883 

884 travels = travels.filter(cond_main_origin | cond_main_dest | cond_stops).distinct() 

885 

886 elif has_origin: 

887 # Solo se relleno el origen en el buscador 

888 travels = travels.filter( 

889 Q(origin_point__distance_lte=(orig_pt, D(km=rad_orig))) | 

890 (Q(pickup_points__point__distance_lte=(orig_pt, D(km=rad_orig))) & Q(pickup_points__is_deleted=False)) 

891 ).distinct() 

892 

893 elif has_dest: 

894 # Solo se relleno el destino en el buscador 

895 travels = travels.filter( 

896 Q(destination_point__distance_lte=(dest_pt, D(km=rad_dest))) | 

897 (Q(pickup_points__point__distance_lte=(dest_pt, D(km=rad_dest))) & Q(pickup_points__is_deleted=False)) 

898 ).distinct() 

899 

900 # Anotaciones de distancia para ordenar despues 

901 if has_origin: 

902 travels = travels.annotate(dist_origin=Distance('origin_point', orig_pt)) 

903 if has_dest: 

904 travels = travels.annotate(dist_dest=Distance('destination_point', dest_pt)) 

905 

906 active_block_for_me = UsersDenied.objects.filter( 

907 id_travel=OuterRef('pk'), 

908 user_type__code=user.user_type.code, 

909 is_deleted=False 

910 ) 

911 

912 travels = travels.annotate( 

913 is_blocked_for_me=Exists(active_block_for_me) 

914 ).filter(is_blocked_for_me=False) 

915 

916 # Si se especifican usuarios que deben ser excluidos, se filtran los viajes que los cumplan 

917 if users_deny: 

918 travels = travels.exclude( 

919 creation_user__user_type__code__in=users_deny 

920 ) 

921 travels = travels.filter( 

922 users_denied__user_type__code__in=users_deny, 

923 users_denied__is_deleted=False 

924 ).annotate( 

925 matching_denies=Count('users_denied', distinct=True) 

926 ).filter( 

927 matching_denies=len(set(users_deny)) 

928 ) 

929 

930 logger.debug(f"Travel IDs after excluding user type: {[str(t.id_travel) for t in travels]}") 

931 # Se filtra por la fecha de inicio de busqueda 

932 if date_from: 

933 travels = travels.filter(travel_date__gte=date_from) 

934 

935 # Se filtra por la fecha de fin 

936 if date_until: 

937 travels = travels.filter(travel_date__lt=date_until) 

938 # Se filtra por las preferencias del conductor, si se han especificado 

939 if preferences: 

940 travels = travels.filter( 

941 creation_user__preferences__pref_type__in=preferences, 

942 creation_user__preferences__is_deleted=False 

943 ).annotate( 

944 matching_preferences=Count('creation_user__preferences', distinct=True) 

945 ).filter( 

946 matching_preferences=len(set(preferences)) 

947 ) 

948 # Se filtra por el distintivo ambiental, si se ha especificado 

949 if env_sticker and env_sticker != 'all': 

950 travels = travels.filter(vehicle__env_sticker=env_sticker) 

951 

952 logger.debug(f"Travel IDs after excluding for preferences, env_sticker and users_deny: {[str(t.id_travel) for t in travels]}") 

953 

954 if travel_type and travel_type != 'all': # Se filtra por el tipo de viaje 

955 if travel_type == 'periodic': 

956 travels = travels.filter(is_periodic=True) 

957 elif travel_type == 'punctual': 

958 travels = travels.filter(is_periodic=False) 

959 

960 # Se ordena segun el criterio seleccionado 

961 if sort_by == 'origin' and has_origin: 

962 travels = travels.order_by('dist_origin') 

963 elif sort_by == 'destination' and has_dest: 

964 travels = travels.order_by('dist_dest') 

965 elif sort_by == 'late': 

966 travels = travels.order_by('-travel_date') 

967 else: # recent 

968 travels = travels.order_by('travel_date') 

969 

970 return travels