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
« 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
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
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
26# Logger para ir almacenando los logs
27logger = logging.getLogger(__name__)
29# Estados de solicitud que impiden volver a solicitar plaza en un viaje
30ACTIVE_REQUEST_STATES = ['accepted', 'pending', 'validated', 'unvalidated']
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 """
41 # Margen tras la hora de llegada prevista antes de dar un viaje por finalizado
42 EXPIRATION_MARGIN = timedelta(hours=2)
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)
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)
60 travel.remaining_seats -= 1
61 travel.save()
63 logger.info(f"Seat reserved for travel {travel.id_travel}. Remaining seats updated to {travel.remaining_seats}.")
64 return travel
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)
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
83 travel.remaining_seats += 1
84 travel.save()
86 logger.info(f"Seat released for travel {travel.id_travel}. Remaining seats updated to {travel.remaining_seats}.")
87 return travel, True
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()
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()
109 travel_list = Travel.objects.filter(id_travel__in=travel_ids, is_deleted=False)
111 # Se saca el estado pending para asignarlo a las nuevas solicitudes
112 pending_state = RequestStates.objects.get(code='pending')
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}"
126 # Se crea la notificacion
127 Notifications.objects.create(
128 id_user=travel.creation_user,
129 content=msg,
130 is_deleted=False
131 )
133 return requests_created
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
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 }
154 # Estados que un cliente puede pedir por la API
155 API_REQUESTABLE_STATES = {'accepted', 'rejected'}
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:
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
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)
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)
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)
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)
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 )
213 # Si la solicitud estaba aceptada, pero se elimina
214 elif current_status == 'accepted' and new_status_code in ['rejected']:
216 TravelService.release_seat(travel.pk)
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 )
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 )
232 request_travel.status = new_status_obj
233 request_travel.save()
234 return request_travel
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
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
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)
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)
278 request_to_remove.status = RequestStates.objects.get(code='rejected')
279 request_to_remove.save()
281 # El asiento se libera con el metodo que lo centraliza
282 travel, _ = TravelService.release_seat(travel.pk)
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.")
292 return travel, passenger
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)
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)
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)
323 # Se actualiza el estado a validado
324 request_travel.status = RequestStates.objects.get(code='validated')
325 request_travel.save()
327 logger.info(f"Passenger {request_travel.user.username} validated for travel {travel.id_travel} with code {code}")
328 return request_travel
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}"
341 with transaction.atomic():
342 # Se cambia el estado del viaje a finalizado
343 travel.state = TravelStates.objects.get(code='fnd')
344 travel.save()
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 )
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 )
365 logger.info(f"Travel {travel.id_travel} finished. {unvalidated_count} passengers marked as unvalidated.")
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 )
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 )
381 logger.info(f"Notifications sent to {validated_requests.count()} validated passengers for travel {travel.id_travel}.")
382 return unvalidated_count
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()
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 )
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))
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
418 return finished_count, unvalidated_count
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
432 # Se sacan las solicitudes del viaje
433 request_travels = travel.requested_travels.filter(is_deleted=False)
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."
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}.")
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 )
459 return len(notifications)
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')
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()
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()
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)
491 return travel
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')
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
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 )
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()
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()
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()
529 travel.vehicle = vehicle.first()
530 travel.save()
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()
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.")
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.")
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')
563 # El origen y el destino siempre vienen, aunque no cambien
564 origin = data.get('origin')
565 destination = data.get('destination')
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
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')
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
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)
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()
593 if duration and duration != travel.duration_minutes:
594 travel.duration_minutes = duration
595 travel.save()
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 )
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()
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 )
630 # De puntual a periódico
631 if is_periodic:
632 TravelService._make_periodic(travel, data)
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')
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()
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()
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()
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()
661 active_state = TravelStates.objects.get(code='active')
663 # Generar las fechas para los nuevos viajes hijos
664 current_date = travel.travel_date + timedelta(days=interval_days)
665 new_travels = []
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)
690 # Se guardan los viajes hijos
691 created_travels = Travel.objects.bulk_create(new_travels)
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)
697 child_pickups = []
698 child_denied = []
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 ))
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 ))
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)
721 logger.info(f"Changed travel {travel.id_travel} to periodic. Created {len(created_travels)} new travels.")
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 )
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()
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}.")
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)
769 point.is_reached = True
770 point.save()
771 return point
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)
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')
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)
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
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
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')
819 date_from = None
820 date_until = None
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
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)
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', [])
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')})")
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)
846 has_origin = orig_lat != 0 or orig_lng != 0
847 has_dest = dest_lat != 0 or dest_lng != 0
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')
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 )
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 )
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 )
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 )
884 travels = travels.filter(cond_main_origin | cond_main_dest | cond_stops).distinct()
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()
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()
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))
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 )
912 travels = travels.annotate(
913 is_blocked_for_me=Exists(active_block_for_me)
914 ).filter(is_blocked_for_me=False)
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 )
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)
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)
952 logger.debug(f"Travel IDs after excluding for preferences, env_sticker and users_deny: {[str(t.id_travel) for t in travels]}")
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)
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')
970 return travels