Coverage for users\services\user_service.py: 98.7%
178 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
3from django.db import connection, transaction
4from django.db.models import Q
5from django.utils import timezone
7from travels.models import RequestTravels, Travel
8from users.models import Criteria, Device, DriverRatings, Preferences, PrefTypes
9from users.services.auth_service import AuthService
10from users.services.exceptions import (IncorrectPasswordError, NoUpcomingTravelsError, RequestStatusMissingError,
11 RequestTravelNotValidatedError, UserNotFoundError, VehicleHasActiveTravelsError, VehicleSeatsInsufficientError)
13# Logger para ir almacenando los logs
14logger = logging.getLogger(__name__)
16# Numero maximo de viajes que devuelve la pantalla de inicio
17MAX_NEXT_TRAVELS = 3
19# Numero minimo de asientos que puede tener un vehiculo
20MIN_VEHICLE_SEATS = 2
22# Servicio con la logica de negocio del subsistema de usuarios
23class UserService:
24 """
25 Centraliza las reglas de negocio de los usuarios: perfil, preferencias,
26 listados propios (vehiculos, notificaciones, viajes, solicitudes), valoraciones y vehiculos.
27 """
29 # Busca un usuario activo por nombre de usuario o por correo
30 @staticmethod
31 def find_by_username_or_email(identifier):
32 """
33 El cliente manda un unico campo en el que se verifican ambas cosas, si trae
34 una arroba se busca por correo y si no por nombre de usuario, el nombre de usuario no admite arrobas
35 """
36 from users.models import Users, normalize_email
37 active = Users.objects.filter(is_deleted=False)
38 if '@' in identifier:
39 user = active.filter(email=normalize_email(identifier)).first()
40 else:
41 user = active.filter(username=identifier).first()
42 if user is None:
43 raise UserNotFoundError(identifier)
44 return user
46 # Guarda la imagen de perfil del usuario y devuelve el nombre del archivo
47 @staticmethod
48 def update_profile_picture(user, picture):
49 user.profile_picture = picture
50 user.save()
51 logger.info(f"Profile picture updated for user ID: {user.id}")
52 return user.profile_picture.name.split('/')[-1]
54 # Borra la imagen de perfil. Devuelve False si no habia ninguna que borrar
55 @staticmethod
56 def delete_profile_picture(user):
57 if not user.profile_picture:
58 return False
59 # Se borra el archivo del almacenamiento y despues la referencia
60 user.profile_picture.delete(save=False)
61 user.profile_picture = None
62 user.save()
63 logger.info(f"Profile picture deleted for user ID: {user.id}")
64 return True
66 # Cambia la contraseña del usuario comprobando antes la actual
67 @staticmethod
68 def change_password(user, current_password, new_password):
69 if not user.check_password(current_password):
70 logger.warning(f"Incorrect current password provided for user ID: {user.id}")
71 raise IncorrectPasswordError(user.id)
72 AuthService.assert_password_is_acceptable(new_password, user=user)
74 user.set_password(new_password)
75 user.save()
77 # Al cambiar la contraseña se revocan todas las sesiones
78 AuthService.revoke_all_sessions(user)
79 logger.info(f"Password changed successfully for user ID: {user.id}")
80 return user
82 # Devuelve las preferencias activas del usuario
83 @staticmethod
84 def list_preferences(user):
85 return user.preferences.filter(is_deleted=False).order_by('pref_type')
87 # Deja las preferencias del usuario en exactamente las que le pasen
88 @staticmethod
89 def update_preferences(user, pref_codes):
90 """
91 Sustituye las preferencias del usuario por las de la lista de codigos
92 Los codigos que no existan en el catalogo se ignoran sin fallar
93 Devuelve el numero de preferencias activas resultante
94 """
95 with transaction.atomic():
96 # Solo se tienen en cuenta los codigos que existen en el catalogo
97 wanted = set(
98 PrefTypes.objects.filter(
99 code__in=pref_codes, is_deleted=False
100 ).values_list('code', flat=True)
101 )
103 # Todas las filas del usuario, incluidas las ya marcadas como borradas
104 existing = {pref.pref_type_id: pref for pref in user.preferences.all()}
106 for code in wanted:
107 pref = existing.get(code)
108 if pref is None:
109 Preferences.objects.create(id_user=user, pref_type_id=code)
110 elif pref.is_deleted:
111 # Se revive la fila que ya existia en vez de crear otra
112 pref.is_deleted = False
113 pref.deleted_at = None
114 pref.save()
116 # Lo que ya no esta en la lista se marca como borrado
117 for code, pref in existing.items():
118 if code not in wanted and not pref.is_deleted:
119 pref.is_deleted = True
120 pref.deleted_at = timezone.now()
121 pref.save()
123 logger.info(f"Preferences updated successfully for user ID: {user.id}")
124 return len(wanted)
126 # Marca como borradas todas las preferencias activas del usuario
127 @staticmethod
128 def clear_preferences(user):
129 """
130 Devuelve cuantas preferencias se han quitado.
131 """
132 cleared = user.preferences.filter(is_deleted=False).update(
133 is_deleted=True, deleted_at=timezone.now()
134 )
135 logger.info(f"Cleared {cleared} preferences for user ID: {user.id}")
136 return cleared
138 # Devuelve los vehiculos activos del usuario
139 @staticmethod
140 def list_vehicles(user):
141 return user.vehicles.filter(is_deleted=False)
143 # Devuelve las notificaciones del usuario ordenadas por fecha
144 @staticmethod
145 def list_notifications(user, ordering='desc'):
146 order_field = 'date' if ordering == 'asc' else '-date'
147 return user.notifications.filter(is_deleted=False).order_by(order_field)
149 # Cuenta las notificaciones que el usuario aun no ha leido
150 @staticmethod
151 def count_unread_notifications(user):
152 return user.notifications.filter(is_deleted=False, read=False).count()
154 # Devuelve la media por criterio y el total de valoraciones de un conductor
155 @staticmethod
156 def get_driver_ratings(user):
157 """
158 Las medias las calcula el procedimiento almacenado getdriverratings, la llamada se realiza aqui
159 """
160 with connection.cursor() as cursor:
161 cursor.execute(
162 "SELECT code, avg_score, num_ratings FROM public.getdriverratings(%s)",
163 [str(user.id)],
164 )
165 results = cursor.fetchall()
167 # Sin valoraciones se devuelve la forma vacia que espera la app
168 if not results:
169 return {"results": {"none": 0}, "count": 0}
171 return {
172 "results": {
173 row[0]: float(row[1]) if row[1] is not None else None
174 for row in results
175 },
176 "count": float(results[0][2]) if results[0][2] is not None else None,
177 }
179 # Registra la valoracion de un conductor tras un viaje validado
180 @staticmethod
181 def rate_driver(user, request_travel_id, results):
182 """
183 results es un diccionario {codigo_de_criterio: nota}, los criterios que no existan se ignoran sin fallar
184 Al terminar, la solicitud pasa a unvalidated para que no se pueda valorar dos veces el mismo viaje.
185 """
186 # El usuario tiene que tener esa solicitud y estar validada
187 request_travel = RequestTravels.objects.filter(
188 id=request_travel_id, user=user, status__code='validated'
189 ).first()
190 if not request_travel:
191 logger.error("The specified request_travel is not found or not in the correct status")
192 raise RequestTravelNotValidatedError(request_travel_id)
194 driver = request_travel.id_travel.creation_user
196 with transaction.atomic():
197 for criteria_code, score in results.items():
198 # Se va puntuando por cada criterio
199 criteria_obj = Criteria.objects.filter(code=criteria_code).first()
200 if not criteria_obj:
201 logger.error(f"Criteria '{criteria_code}' not found, skipping.")
202 continue
203 DriverRatings.objects.create(
204 id_user=user,
205 id_driver=driver,
206 criteria=criteria_obj,
207 score=score,
208 )
210 # Se cambia el estado a invalidado para que ya no se pueda valorar de nuevo ese viaje
211 unvalidated_status = request_travel.status.__class__.objects.filter(
212 code='unvalidated'
213 ).first()
214 if not unvalidated_status: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 logger.error("Unvalidated status not found, unable to update request_travel status.")
216 raise RequestStatusMissingError('unvalidated')
218 request_travel.status = unvalidated_status
219 request_travel.save()
221 logger.info(
222 f"Driver ratings saved successfully for user ID: {user.id} and driver ID: {driver.id}"
223 )
224 return driver
226 # Devuelve los viajes creados por el usuario, filtrados por tipo
227 @staticmethod
228 def list_created_travels(user, travel_type='all'):
229 """
230 travel_type acepta pending (futuros o en curso), past (finalizados)
231 y all, que devuelve todos.
232 """
233 today = timezone.now().date()
234 qs = user.created_travels.filter(is_deleted=False)
236 if travel_type == 'pending':
237 return qs.filter(
238 Q(travel_date__gte=today, state__code='active') | Q(state__code='started')
239 ).order_by('travel_date')
241 if travel_type == 'past':
242 return qs.filter(
243 Q(travel_date__lt=today) | Q(state__code='fnd')
244 ).order_by('-travel_date')
246 return qs
248 # Devuelve las solicitudes que ha hecho el usuario, filtradas por tipo
249 @staticmethod
250 def list_own_requests(user, request_type='all'):
251 """
252 request_type acepta 'pending' (aun sin responder o rechazadas),
253 active (aceptadas), past (ya viajadas) y all, que devuelve todas.
254 """
255 today = timezone.now().date()
256 qs = user.requested_travels.filter(is_deleted=False)
258 if request_type == 'pending':
259 return qs.filter(
260 status__code__in=['pending', 'rejected'],
261 id_travel__travel_date__gte=today,
262 ).order_by('id_travel__travel_date')
264 if request_type == 'active':
265 return qs.filter(status__code='accepted').order_by('id_travel__travel_date')
267 if request_type == 'past':
268 return qs.filter(
269 status__code__in=['validated', 'unvalidated']
270 ).order_by('-id_travel__travel_date')
272 return qs
274 # Devuelve las solicitudes pendientes de aprobar que ha recibido el usuario
275 @staticmethod
276 def list_received_requests(user):
277 return RequestTravels.objects.filter(
278 id_travel__creation_user=user,
279 status__code='pending',
280 is_deleted=False,
281 ).order_by('-created_at')
283 # Devuelve los proximos viajes del usuario, como conductor y como pasajero
284 @staticmethod
285 def get_next_travels(user, max_travels=MAX_NEXT_TRAVELS):
286 """
287 Mezcla los viajes que ha creado con aquellos en los que va de pasajero,
288 ordenados por fecha. Primero se ponen los viajes en curso, y despues por fecha
289 Devuelve una lista de diccionarios lista para serializar.
290 """
291 from api.serializers.travel_serializer import TravelSerializer
293 today = timezone.now().date()
295 started_travel = user.created_travels.filter(state='started', is_deleted=False).order_by('travel_date').first()
297 # El viaje en curso ocupa una de las plazas del maximo
298 remaining = max_travels - (1 if started_travel else 0)
300 created_travels = list(
301 user.created_travels.filter(
302 state='active', is_deleted=False
303 ).order_by('travel_date')[:remaining]
304 )
306 requested_reqs = list(
307 RequestTravels.objects.filter(
308 user=user,
309 status__code='accepted',
310 id_travel__travel_date__gte=today,
311 id_travel__state='active',
312 id_travel__is_deleted=False,
313 is_deleted=False,
314 ).order_by('id_travel__travel_date').select_related('id_travel', 'status')[:remaining]
315 )
317 # Se combinan los viajes creados y solicitados, para sacar los siguientes 3 viajes
318 candidates = (
319 [(t, False, None) for t in created_travels] + [(req.id_travel, True, req) for req in requested_reqs]
320 )
321 candidates.sort(key=lambda x: x[0].travel_date)
322 candidates = candidates[:remaining]
324 if started_travel:
325 candidates = [(started_travel, False, None)] + candidates
327 if not candidates:
328 logger.info(f"No travels found for user ID: {user.id}")
329 raise NoUpcomingTravelsError(user.id)
331 result = []
332 for travel, is_request, req_obj in candidates:
333 result.append({
334 "id": req_obj.id if is_request else travel.id_travel,
335 "data": TravelSerializer(travel).data,
336 "is_request": is_request,
337 "code": req_obj.validation_code if is_request else None,
338 "status": req_obj.status.code if is_request else travel.state.code,
339 })
341 logger.info(f"Next travels retrieved successfully for user ID: {user.id}")
342 return result
344 # Comprueba que el vehiculo puede pasar a tener ese numero de asientos
345 @staticmethod
346 def check_new_seats(user, vehicle, new_seats):
347 """
348 Un vehiculo no puede quedarse con menos plazas de las que ya ha publicado
349 en alguno de sus viajes activos, ni bajar del minimo.
350 """
351 if new_seats < MIN_VEHICLE_SEATS:
352 logger.error("Number of seats cannot be less than 2")
353 raise VehicleSeatsInsufficientError(new_seats)
355 # El viaje no cuenta al conductor, por eso se compara con new_seats - 1
356 if Travel.objects.filter(
357 creation_user=user,
358 vehicle_id=vehicle.id_vehicle,
359 num_seats__gt=(new_seats - 1),
360 state='active',
361 is_deleted=False,
362 ).exists():
363 logger.error("Number of seats is insufficient for associated travels")
364 raise VehicleHasActiveTravelsError(vehicle.id_vehicle)
366 # Comprueba que el vehiculo se puede borrar
367 @staticmethod
368 def check_vehicle_is_free(user, vehicle):
369 if Travel.objects.filter(
370 creation_user=user,
371 vehicle_id=vehicle.id_vehicle,
372 state='active',
373 is_deleted=False,
374 ).exists():
375 logger.error(f"Vehicle {vehicle.id_vehicle} has active travels associated")
376 raise VehicleHasActiveTravelsError(vehicle.id_vehicle)
379 # Registra el dispositivo del usuario, o lo actualiza si el token ya existia
380 @staticmethod
381 def register_device(user, fcm_token, platform=''):
382 """
383 El token de FCM identifica al dispositivo, no al usuario, si alguien
384 inicia sesion en un movil donde ya habia otra cuenta, el token cambia de
385 dueño en lugar de duplicarse.
386 """
387 device, created = Device.objects.update_or_create(
388 fcm_token=fcm_token,
389 defaults={
390 'id_user': user,
391 'platform': platform,
392 'is_deleted': False,
393 },
394 )
395 logger.info(
396 f"Device {'registered' if created else 'updated'} for user ID: {user.id}"
397 )
398 return device