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

1import logging 

2 

3from django.db import connection, transaction 

4from django.db.models import Q 

5from django.utils import timezone 

6 

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) 

12 

13# Logger para ir almacenando los logs 

14logger = logging.getLogger(__name__) 

15 

16# Numero maximo de viajes que devuelve la pantalla de inicio 

17MAX_NEXT_TRAVELS = 3 

18 

19# Numero minimo de asientos que puede tener un vehiculo 

20MIN_VEHICLE_SEATS = 2 

21 

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 """ 

28 

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 

45 

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] 

53 

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 

65 

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) 

73 

74 user.set_password(new_password) 

75 user.save() 

76 

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 

81 

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

86 

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 ) 

102 

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

105 

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

115 

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

122 

123 logger.info(f"Preferences updated successfully for user ID: {user.id}") 

124 return len(wanted) 

125 

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 

137 

138 # Devuelve los vehiculos activos del usuario 

139 @staticmethod 

140 def list_vehicles(user): 

141 return user.vehicles.filter(is_deleted=False) 

142 

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) 

148 

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

153 

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

166 

167 # Sin valoraciones se devuelve la forma vacia que espera la app 

168 if not results: 

169 return {"results": {"none": 0}, "count": 0} 

170 

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 } 

178 

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) 

193 

194 driver = request_travel.id_travel.creation_user 

195 

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 ) 

209 

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

217 

218 request_travel.status = unvalidated_status 

219 request_travel.save() 

220 

221 logger.info( 

222 f"Driver ratings saved successfully for user ID: {user.id} and driver ID: {driver.id}" 

223 ) 

224 return driver 

225 

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) 

235 

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

240 

241 if travel_type == 'past': 

242 return qs.filter( 

243 Q(travel_date__lt=today) | Q(state__code='fnd') 

244 ).order_by('-travel_date') 

245 

246 return qs 

247 

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) 

257 

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

263 

264 if request_type == 'active': 

265 return qs.filter(status__code='accepted').order_by('id_travel__travel_date') 

266 

267 if request_type == 'past': 

268 return qs.filter( 

269 status__code__in=['validated', 'unvalidated'] 

270 ).order_by('-id_travel__travel_date') 

271 

272 return qs 

273 

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

282 

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 

292 

293 today = timezone.now().date() 

294 

295 started_travel = user.created_travels.filter(state='started', is_deleted=False).order_by('travel_date').first() 

296 

297 # El viaje en curso ocupa una de las plazas del maximo 

298 remaining = max_travels - (1 if started_travel else 0) 

299 

300 created_travels = list( 

301 user.created_travels.filter( 

302 state='active', is_deleted=False 

303 ).order_by('travel_date')[:remaining] 

304 ) 

305 

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 ) 

316 

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] 

323 

324 if started_travel: 

325 candidates = [(started_travel, False, None)] + candidates 

326 

327 if not candidates: 

328 logger.info(f"No travels found for user ID: {user.id}") 

329 raise NoUpcomingTravelsError(user.id) 

330 

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

340 

341 logger.info(f"Next travels retrieved successfully for user ID: {user.id}") 

342 return result 

343 

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) 

354 

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) 

365 

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) 

377 

378 

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