Norberto Montalvo García commited on
Commit
2ec59e0
·
unverified ·
2 Parent(s): e353854 6124d0c

Merge pull request #40 from CascoArcilla/HU10

Browse files

Implementacion de nappgin con modalidad sort, perfil ultra flash o sin modalidad

Files changed (31) hide show
  1. tecnicas/controllers/api_controller/rating_napping_controller.py +282 -11
  2. tecnicas/controllers/models_controller/dato_controller.py +0 -4
  3. tecnicas/controllers/views_controller/create_session/panels_create/panel_create_napping_controller.py +20 -1
  4. tecnicas/controllers/views_controller/session_management/details/details_controller.py +15 -8
  5. tecnicas/controllers/views_controller/session_management/details/details_escala_controller.py +1 -1
  6. tecnicas/controllers/views_controller/session_management/details/details_napping_controller.py +133 -32
  7. tecnicas/controllers/views_controller/session_management/details/details_pf_controller.py +11 -2
  8. tecnicas/controllers/views_controller/session_management/monitor/monitor_napping_controller.py +3 -5
  9. tecnicas/controllers/views_controller/sessions_tester/init_session/init_session_napping_controller.py +11 -5
  10. tecnicas/controllers/views_controller/sessions_tester/login_session_tester_controller.py +31 -30
  11. tecnicas/controllers/views_controller/sessions_tester/tests_forms/test_napping_controller.py +175 -7
  12. tecnicas/controllers/views_controller/sessions_tester/tests_forms/test_sort_controller.py +2 -3
  13. tecnicas/forms/create_session/sesion_basic_napping.py +14 -0
  14. tecnicas/static/js/span-notification.js +1 -0
  15. tecnicas/static/js/test-napping-plane.js +253 -0
  16. tecnicas/static/js/test-napping-sort.js +623 -0
  17. tecnicas/static/js/test-napping-ultra-flash.js +235 -0
  18. tecnicas/static/js/test-napping.js +0 -209
  19. tecnicas/templates/tecnicas/components/dialog-nap-puf.html +28 -0
  20. tecnicas/templates/tecnicas/components/dialog-nap-sort.html +29 -0
  21. tecnicas/templates/tecnicas/components/table-napping-puf.html +53 -0
  22. tecnicas/templates/tecnicas/components/table-napping-sorting.html +47 -0
  23. tecnicas/templates/tecnicas/create_sesion/panel-basic-napping.html +17 -2
  24. tecnicas/templates/tecnicas/forms_tester/test_napping.html +12 -7
  25. tecnicas/templates/tecnicas/forms_tester/test_napping_puf.html +172 -0
  26. tecnicas/templates/tecnicas/forms_tester/test_napping_sort.html +198 -0
  27. tecnicas/templates/tecnicas/manage_sesions/details-session-napping.html +25 -5
  28. tecnicas/templates/tecnicas/manage_sesions/details-session-pf.html +33 -24
  29. tecnicas/urls.py +3 -3
  30. tecnicas/views/__init__.py +1 -1
  31. tecnicas/views/apis/rating_napping.py +1 -1
tecnicas/controllers/api_controller/rating_napping_controller.py CHANGED
@@ -1,28 +1,245 @@
1
  from django.http import JsonResponse
2
  from django.http import HttpRequest
3
  from django.db import transaction
4
- from tecnicas.models import Calificacion, DatoPunto, Producto, Participacion
 
5
 
6
 
7
  class RatingNappingController:
8
  @staticmethod
9
- def saveRatingCoordinates(request: HttpRequest, data: list):
10
  participation = Participacion.objects.get(
11
  id=request.session["id_participation"]
12
  )
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  try:
15
  with transaction.atomic():
 
16
  products_map = RatingNappingController.getProductsMap(
17
  participation.tecnica)
18
 
 
19
  existing_ratings_map = RatingNappingController.getExistingRatingsMap(
20
- participation.tecnica, participation.catador
21
- )
 
 
 
 
 
22
 
 
23
  new_ratings = []
24
  ids_products = products_map.keys()
25
- for item in data:
26
  product_id = int(item["idProduct"])
27
  if product_id not in existing_ratings_map and product_id in ids_products:
28
  new_ratings.append(
@@ -37,16 +254,16 @@ class RatingNappingController:
37
  if new_ratings:
38
  Calificacion.objects.bulk_create(new_ratings)
39
  existing_ratings_map = RatingNappingController.getExistingRatingsMap(
40
- participation.tecnica, participation.catador
41
- )
42
 
 
43
  existing_points_map = RatingNappingController.getExistingPointsMap(
44
  existing_ratings_map.values())
45
 
46
  points_to_create = []
47
  points_to_update = []
48
 
49
- for item in data:
50
  product_id = int(item["idProduct"])
51
  rating = existing_ratings_map.get(product_id)
52
 
@@ -71,11 +288,65 @@ class RatingNappingController:
71
  if points_to_update:
72
  DatoPunto.objects.bulk_update(points_to_update, ['x', 'y'])
73
 
74
- return JsonResponse({"message": "Datos guardados exitosamente"})
75
 
76
  except Exception as e:
77
- print("ERROR:", e)
78
- return JsonResponse({"error": "Error al procesar datos"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  @staticmethod
81
  def getProductsMap(id_tecnica):
 
1
  from django.http import JsonResponse
2
  from django.http import HttpRequest
3
  from django.db import transaction
4
+ from tecnicas.models import Calificacion, DatoPunto, Producto, Participacion, Palabra, GrupoProducto, TecnicaModalidad
5
+ from tecnicas.forms import ListWordsForm
6
 
7
 
8
  class RatingNappingController:
9
  @staticmethod
10
+ def saveRatingCoordinates(request: HttpRequest, data: list | dict):
11
  participation = Participacion.objects.get(
12
  id=request.session["id_participation"]
13
  )
14
 
15
+ name_mod = TecnicaModalidad.objects.get(
16
+ tecnica=participation.tecnica
17
+ ).modalidad.nombre.lower()
18
+
19
+ # Branch based on modality
20
+ if name_mod == 'sorting':
21
+ return RatingNappingController.processSortingMode(
22
+ data, participation
23
+ )
24
+
25
+ if name_mod in ['sin modalidad', 'perfil ultra flash']:
26
+ return RatingNappingController.processNappOrPUF(
27
+ data, participation
28
+ )
29
+
30
+ else:
31
+ return JsonResponse({"error": "Modalidad no soportada"})
32
+
33
+ @staticmethod
34
+ def processSortingMode(data: dict, participation):
35
+ try:
36
+ with transaction.atomic():
37
+ # Extract products array (always present)
38
+ products = data.get("products", [])
39
+ if not products:
40
+ return JsonResponse({"error": "No se proporcionaron productos"})
41
+
42
+ existing_ratings_map, products_map = RatingNappingController.savePoints(
43
+ isSorting=True, products=products, participation=participation
44
+ )
45
+
46
+ # Process groups if they exist
47
+ groups = data.get("groups", {})
48
+ if groups:
49
+ RatingNappingController.processGroupsForSorting(
50
+ products, groups, participation, existing_ratings_map, products_map
51
+ )
52
+ else:
53
+ RatingNappingController.deleteAllGroups(participation)
54
+
55
+ return JsonResponse({"message": "Datos guardados exitosamente"})
56
+
57
+ except Exception as e:
58
+ print("ERROR:", e)
59
+ import traceback
60
+ traceback.print_exc()
61
+ return JsonResponse({"error": f"Error al procesar datos: {str(e)}"})
62
+
63
+ @staticmethod
64
+ def processNappOrPUF(data: list, participation):
65
+ try:
66
+ with transaction.atomic():
67
+ existing_ratings_map, products_map = RatingNappingController.savePoints(
68
+ isSorting=False, products=data, participation=participation
69
+ )
70
+
71
+ RatingNappingController.processWordsForRatings(
72
+ data, existing_ratings_map
73
+ )
74
+
75
+ return JsonResponse({"message": "Datos guardados exitosamente"})
76
+
77
+ except Exception as e:
78
+ print("ERROR:", e)
79
+ return JsonResponse({"error": "Error al procesar datos"})
80
+
81
+ @staticmethod
82
+ def processGroupsForSorting(products, groups, participation, existing_ratings_map, products_map):
83
+ """Process groups for sorting mode
84
+ - Creates/updates GrupoProducto instances
85
+ - Ensures products don't belong to multiple groups
86
+ - Associates words with groups
87
+ """
88
+ # Build mapping of product_id to group_id from products array
89
+ product_to_group = {}
90
+ for product_item in products:
91
+ group_code = product_item.get("group", "")
92
+ if group_code: # Only if product has a group assigned
93
+ product_id = int(product_item["idProduct"])
94
+ if product_id in product_to_group:
95
+ raise ValueError(
96
+ f"Producto {product_id} pertenece a múltiples grupos")
97
+ product_to_group[product_id] = group_code
98
+
99
+ # Get existing groups for this catador and technique
100
+ existing_groups = GrupoProducto.objects.filter(
101
+ tecnica=participation.tecnica,
102
+ catador=participation.catador
103
+ )
104
+
105
+ # Create a map of existing groups by their product composition
106
+ # We'll identify groups by the set of products they contain
107
+ existing_groups_map = {}
108
+ for group in existing_groups:
109
+ product_ids = set(group.productos.values_list('id', flat=True))
110
+ key = frozenset(product_ids)
111
+ existing_groups_map[key] = group
112
+
113
+ # Build new groups structure
114
+ groups_to_create = []
115
+ groups_to_update = []
116
+ group_products_map = {} # group_id -> [product_ids]
117
+
118
+ # Organize products by group
119
+ for product_id, group_code in product_to_group.items():
120
+ if group_code not in group_products_map:
121
+ group_products_map[group_code] = []
122
+ group_products_map[group_code].append(product_id)
123
+
124
+ # Process each group
125
+ for group_code, product_ids in group_products_map.items():
126
+ product_set = frozenset(product_ids)
127
+
128
+ # Check if this group already exists
129
+ if product_set in existing_groups_map:
130
+ group = existing_groups_map[product_set]
131
+ groups_to_update.append((group, group_code))
132
+ else:
133
+ # Create new group
134
+ group = GrupoProducto(
135
+ tecnica=participation.tecnica,
136
+ catador=participation.catador
137
+ )
138
+ groups_to_create.append((group, product_ids, group_code))
139
+
140
+ # Create new groups
141
+ created_groups = []
142
+ for group, product_ids, group_code in groups_to_create:
143
+ group.save() # Save first to get ID for M2M
144
+
145
+ # Add products to group
146
+ productos = [products_map[pid]
147
+ for pid in product_ids if pid in products_map]
148
+ group.productos.set(productos)
149
+
150
+ created_groups.append((group, group_code))
151
+
152
+ # Combine created and existing groups for word processing
153
+ all_groups_for_words = created_groups + groups_to_update
154
+
155
+ # Delete groups that no longer exist
156
+ current_group_sets = set(frozenset(pids)
157
+ for pids in group_products_map.values())
158
+ for product_set, group in existing_groups_map.items():
159
+ if product_set not in current_group_sets:
160
+ group.delete()
161
+
162
+ # Process words for groups
163
+ if groups:
164
+ RatingNappingController.processWordsForGroups(
165
+ groups, all_groups_for_words
166
+ )
167
+
168
+ @staticmethod
169
+ def deleteAllGroups(participation):
170
+ GrupoProducto.objects.filter(
171
+ tecnica=participation.tecnica,
172
+ catador=participation.catador
173
+ ).delete()
174
+
175
+ @staticmethod
176
+ def processWordsForGroups(groups_data, groups_list):
177
+ """Process and associate words to groups
178
+ - Creates words that don't exist
179
+ - Associates words to GrupoProducto instances
180
+ - Handles concurrency
181
+ """
182
+ # Collect all unique words from all groups
183
+ all_words = set()
184
+ for group_id, words in groups_data.items():
185
+ if words:
186
+ all_words.update(words)
187
+
188
+ if not all_words:
189
+ # No words to process, just clear existing words from groups
190
+ for grupo, _ in groups_list:
191
+ grupo.palabras.clear()
192
+ return
193
+
194
+ # Get existing words
195
+ existing_words = Palabra.objects.filter(
196
+ nombre_palabra__in=all_words
197
+ )
198
+ existing_words_map = {w.nombre_palabra: w for w in existing_words}
199
+
200
+ # Create missing words with concurrency handling
201
+ word_objects = {}
202
+ for word_name in all_words:
203
+ if word_name in existing_words_map:
204
+ word_objects[word_name] = existing_words_map[word_name]
205
+ else:
206
+ word_obj, created = Palabra.objects.get_or_create(
207
+ nombre_palabra=word_name
208
+ )
209
+ word_objects[word_name] = word_obj
210
+
211
+ # Associate words with groups
212
+ for grupo, group_id in groups_list:
213
+ words = groups_data.get(group_id, [])
214
+ if words:
215
+ words_to_set = [word_objects[word_name]
216
+ for word_name in words if word_name in word_objects]
217
+ grupo.palabras.set(words_to_set)
218
+ else:
219
+ grupo.palabras.clear()
220
+
221
+ @staticmethod
222
+ def savePoints(isSorting: bool, products, participation):
223
  try:
224
  with transaction.atomic():
225
+ # Get products map for validation
226
  products_map = RatingNappingController.getProductsMap(
227
  participation.tecnica)
228
 
229
+ # Get existing ratings map
230
  existing_ratings_map = RatingNappingController.getExistingRatingsMap(
231
+ participation.tecnica, participation.catador)
232
+
233
+ if not isSorting:
234
+ validation_result = RatingNappingController.validateWords(
235
+ products)
236
+ if validation_result is not None:
237
+ return validation_result
238
 
239
+ # Create new ratings for products that don't have them
240
  new_ratings = []
241
  ids_products = products_map.keys()
242
+ for item in products:
243
  product_id = int(item["idProduct"])
244
  if product_id not in existing_ratings_map and product_id in ids_products:
245
  new_ratings.append(
 
254
  if new_ratings:
255
  Calificacion.objects.bulk_create(new_ratings)
256
  existing_ratings_map = RatingNappingController.getExistingRatingsMap(
257
+ participation.tecnica, participation.catador)
 
258
 
259
+ # Process DatoPunto instances from products array
260
  existing_points_map = RatingNappingController.getExistingPointsMap(
261
  existing_ratings_map.values())
262
 
263
  points_to_create = []
264
  points_to_update = []
265
 
266
+ for item in products:
267
  product_id = int(item["idProduct"])
268
  rating = existing_ratings_map.get(product_id)
269
 
 
288
  if points_to_update:
289
  DatoPunto.objects.bulk_update(points_to_update, ['x', 'y'])
290
 
291
+ return (existing_ratings_map, products_map)
292
 
293
  except Exception as e:
294
+ print(e)
295
+ return JsonResponse({"error": "Error al guardar los puntos"})
296
+
297
+ @staticmethod
298
+ def validateWords(data: list):
299
+ for item in data:
300
+ words = item.get("words", [])
301
+ if words:
302
+ dic_words = {}
303
+ for index, word in enumerate(words, start=1):
304
+ dic_words[f"palabra_{index}"] = word
305
+
306
+ form = ListWordsForm(dic_words, new_words=words)
307
+ if not form.is_valid():
308
+ errors = []
309
+ for field, error_list in form.errors.items():
310
+ errors.extend(error_list)
311
+ return JsonResponse({"error": f"Error en validación de palabras: {', '.join(errors)}"})
312
+ return None
313
+
314
+ @staticmethod
315
+ def processWordsForRatings(data: list, existing_ratings_map: dict):
316
+ all_words = set()
317
+ for item in data:
318
+ words = item.get("words", [])
319
+ if words:
320
+ all_words.update(words)
321
+
322
+ if not all_words:
323
+ return
324
+
325
+ existing_words = Palabra.objects.filter(
326
+ nombre_palabra__in=all_words
327
+ )
328
+ existing_words_map = {w.nombre_palabra: w for w in existing_words}
329
+
330
+ word_objects = {}
331
+ for word_name in all_words:
332
+ if word_name in existing_words_map:
333
+ word_objects[word_name] = existing_words_map[word_name]
334
+ else:
335
+ word_obj, created = Palabra.objects.get_or_create(
336
+ nombre_palabra=word_name
337
+ )
338
+ word_objects[word_name] = word_obj
339
+
340
+ for item in data:
341
+ words = item.get("words", [])
342
+ if words:
343
+ product_id = int(item["idProduct"])
344
+ rating = existing_ratings_map.get(product_id)
345
+
346
+ if rating:
347
+ words_to_set = [word_objects[word_name]
348
+ for word_name in words]
349
+ rating.palabras.set(words_to_set)
350
 
351
  @staticmethod
352
  def getProductsMap(id_tecnica):
tecnicas/controllers/models_controller/dato_controller.py CHANGED
@@ -48,10 +48,6 @@ class DatoController():
48
  value_rounded = round(decimal_value)
49
  self.value_data = ValorDecimal(valor=value_rounded)
50
 
51
- print(self.value_rating)
52
- print(decimal_value)
53
- print(value_rounded)
54
-
55
  else:
56
  self.value_data = ValorDecimal(valor=self.value_rating)
57
 
 
48
  value_rounded = round(decimal_value)
49
  self.value_data = ValorDecimal(valor=value_rounded)
50
 
 
 
 
 
51
  else:
52
  self.value_data = ValorDecimal(valor=self.value_rating)
53
 
tecnicas/controllers/views_controller/create_session/panels_create/panel_create_napping_controller.py CHANGED
@@ -1,6 +1,6 @@
1
  from .panel_create_controller import PanelCreateController
2
  from django.http import HttpRequest, JsonResponse
3
- from tecnicas.models import Tecnica, TipoTecnica, EstiloPalabra, Producto, SesionSensorial
4
  from django.db import transaction
5
  from tecnicas.utils import deleteDataSession
6
 
@@ -64,6 +64,25 @@ class PanelCreateNappingController(PanelCreateController):
64
  # Third step: Create session and relat with the technique #
65
  #
66
  # /////////////////////////////////////////////////////// #
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  session = SesionSensorial.objects.create(
68
  nombre_sesion=data_basic["nombre_sesion"] if data_basic["nombre_sesion"] != "" else None,
69
  tecnica=technique,
 
1
  from .panel_create_controller import PanelCreateController
2
  from django.http import HttpRequest, JsonResponse
3
+ from tecnicas.models import Tecnica, TipoTecnica, EstiloPalabra, Producto, SesionSensorial, Modalidad, TecnicaModalidad
4
  from django.db import transaction
5
  from tecnicas.utils import deleteDataSession
6
 
 
64
  # Third step: Create session and relat with the technique #
65
  #
66
  # /////////////////////////////////////////////////////// #
67
+ mod = Modalidad.objects.get(
68
+ nombre=data_basic["modalidad"])
69
+
70
+ if not mod:
71
+ raise ValueError("Modalidad no encontrada")
72
+
73
+ technique_mod = TecnicaModalidad.objects.create(
74
+ tecnica=technique,
75
+ modalidad=mod
76
+ )
77
+
78
+ if not technique_mod:
79
+ raise ValueError("Error al guardar la técnica")
80
+
81
+ # /////////////////////////////////////////////////////// #
82
+ #
83
+ # Fourth step: Create session and relat with the technique #
84
+ #
85
+ # /////////////////////////////////////////////////////// #
86
  session = SesionSensorial.objects.create(
87
  nombre_sesion=data_basic["nombre_sesion"] if data_basic["nombre_sesion"] != "" else None,
88
  tecnica=technique,
tecnicas/controllers/views_controller/session_management/details/details_controller.py CHANGED
@@ -41,14 +41,9 @@ class DetallesController():
41
  elif technique.repeticion >= technique.repeticiones_max:
42
  return self.controllGetResponse(error="Se ha alcanzado el número de repeticiones máxima", request=request)
43
 
44
- there_participacions = Participacion.objects.filter(
45
- tecnica=technique).exists()
46
-
47
- if there_participacions:
48
- (is_update_participations,
49
- message) = ParticipacionController.outAllInSession(self.session)
50
- if not is_update_participations:
51
- return self.controllGetResponse(error=message, request=request)
52
 
53
  self.session.activo = True
54
  technique.repeticion = technique.repeticion + 1
@@ -61,3 +56,15 @@ class DetallesController():
61
  }
62
  return redirect(
63
  reverse(self.url_next, kwargs=parameters))
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  elif technique.repeticion >= technique.repeticiones_max:
42
  return self.controllGetResponse(error="Se ha alcanzado el número de repeticiones máxima", request=request)
43
 
44
+ is_update_participations = self.setParticipationsToNoFinished()
45
+ if not is_update_participations:
46
+ return self.controllGetResponse(error="Error al actualizar las participaciones", request=request)
 
 
 
 
 
47
 
48
  self.session.activo = True
49
  technique.repeticion = technique.repeticion + 1
 
56
  }
57
  return redirect(
58
  reverse(self.url_next, kwargs=parameters))
59
+
60
+ def setParticipationsToNoFinished(self):
61
+ there_participacions = Participacion.objects.filter(
62
+ tecnica=self.session.tecnica).exists()
63
+
64
+ if there_participacions:
65
+ (is_update_participations,
66
+ message) = ParticipacionController.outAllInSession(self.session)
67
+
68
+ return is_update_participations
69
+
70
+ return True
tecnicas/controllers/views_controller/session_management/details/details_escala_controller.py CHANGED
@@ -29,7 +29,7 @@ class DetallesEscalasController(DetallesController):
29
 
30
  self.context = {
31
  "sesion": self.session,
32
- "use_technique": technique
33
  }
34
 
35
  # Datos de la escala usada
 
29
 
30
  self.context = {
31
  "sesion": self.session,
32
+ "use_technique": technique.tipo_tecnica.nombre_tecnica
33
  }
34
 
35
  # Datos de la escala usada
tecnicas/controllers/views_controller/session_management/details/details_napping_controller.py CHANGED
@@ -3,7 +3,7 @@ from django.shortcuts import redirect
3
  from django.urls import reverse
4
  from django.db.models import F
5
  from .details_controller import DetallesController
6
- from tecnicas.models import SesionSensorial, Presentador, Modalidad, TecnicaModalidad, Catador, Participacion, DatoPunto, Calificacion
7
  from tecnicas.utils import defaultdict_to_dict
8
  from collections import defaultdict
9
 
@@ -20,31 +20,36 @@ class DetallesNappingController(DetallesController):
20
  }
21
 
22
  self.defineStatus()
23
- self.setOptionesMode()
24
- self.setDataTableNoMode()
25
 
26
  return self.context
27
 
28
  def defineStatus(self):
29
  repetition = self.session.tecnica.repeticion
 
 
 
 
 
 
 
30
 
31
- if not repetition and not self.session.activo:
32
  self.context["status"] = "Listo para iniciar la sesión con Napping"
33
- elif not repetition and self.session.activo:
34
- self.context["status"] = "Sesión con Napping en curso"
35
- elif repetition == 1 and not self.session.activo:
36
- self.context["status"] = "En espera de la siguiente acción"
37
- else:
38
- self.context["status"] = "En espera de la siguiente acción"
39
 
40
  def controllPostResponse(self, request: HttpRequest, action: str):
 
41
  if action == "start_sin_modalidad":
42
- name_mode = action.replace("start_", "").replace("_", " ")
43
- response = self.startNapping(request=request, name_mode=name_mode)
 
 
44
 
45
- if action == "start_perfil_ultra_flash":
46
- name_mode = action.replace("start_", "").replace("_", " ")
47
- return self.controllGetResponse(error="Trabajando en la modalidad", request=request)
48
 
49
  elif action == "delete_session":
50
  self.deleteSesorialSession()
@@ -57,17 +62,15 @@ class DetallesNappingController(DetallesController):
57
 
58
  return response
59
 
60
- def startNapping(self, request: HttpRequest, name_mode: str):
61
  if request.user.user_presentador.user.username != self.session.creadoPor.user.username:
62
  return self.controllGetResponse(error="Solo el presentador que crea la sesión puede iniciar la repetición", request=request)
63
  elif self.session.activo:
64
  return self.controllGetResponse(error="La sesión ya está activada", request=request)
65
 
66
- tecnique_mode = TecnicaModalidad.objects.get_or_create(
67
- tecnica=self.session.tecnica, modalidad=Modalidad.objects.get(nombre=name_mode), usando=True)
68
-
69
- if not tecnique_mode:
70
- return self.controllGetResponse(error="Modalidad no encontrada", request=request)
71
 
72
  self.session.activo = True
73
  self.session.save()
@@ -78,7 +81,7 @@ class DetallesNappingController(DetallesController):
78
  return redirect(
79
  reverse(self.url_next, kwargs=parameters))
80
 
81
- def setDataTableNoMode(self):
82
  participations = Participacion.objects.filter(
83
  tecnica=self.session.tecnica).select_related("catador")
84
  testers = [participation.catador for participation in participations]
@@ -110,17 +113,115 @@ class DetallesNappingController(DetallesController):
110
  self.context["coordinates_no_mode"] = defaultdict_to_dict(
111
  coordinates_by_product)
112
 
 
 
 
 
 
 
 
113
  self.context["there_data"] = True
114
 
115
- def setOptionesMode(self):
116
- modes = Modalidad.objects.all()
117
- technique_modes = TecnicaModalidad.objects.filter(
118
- tecnica=self.session.tecnica)
119
 
120
- if not technique_modes.exists():
121
- self.context["modes"] = modes
122
- else:
123
- use_modes = technique_modes.values_list("modalidad", flat=True)
124
 
125
- self.context["modes"] = modes.exclude(
126
- id__in=use_modes)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from django.urls import reverse
4
  from django.db.models import F
5
  from .details_controller import DetallesController
6
+ from tecnicas.models import SesionSensorial, Presentador, Modalidad, TecnicaModalidad, Catador, Participacion, DatoPunto, Calificacion, GrupoProducto
7
  from tecnicas.utils import defaultdict_to_dict
8
  from collections import defaultdict
9
 
 
20
  }
21
 
22
  self.defineStatus()
23
+ self.setIsEndSession()
24
+ self.setDataTable()
25
 
26
  return self.context
27
 
28
  def defineStatus(self):
29
  repetition = self.session.tecnica.repeticion
30
+ mod = TecnicaModalidad.objects.get(
31
+ tecnica=self.session.tecnica)
32
+
33
+ self.context["mod_tech"] = mod.modalidad.nombre
34
+ self.context["mode"] = mod.modalidad.nombre
35
+ if mod.modalidad.nombre == "sin modalidad":
36
+ self.context["mod_tech"] = "No se usa modalidad"
37
 
38
+ if not self.session.activo:
39
  self.context["status"] = "Listo para iniciar la sesión con Napping"
40
+ elif self.session.activo:
41
+ self.context["status"] = "Sesión con en curso"
 
 
 
 
42
 
43
  def controllPostResponse(self, request: HttpRequest, action: str):
44
+ print(action)
45
  if action == "start_sin_modalidad":
46
+ response = self.startNapping(request=request)
47
+
48
+ elif action == "start_perfil_ultra_flash":
49
+ response = self.startNapping(request=request)
50
 
51
+ elif action == "start_sorting":
52
+ response = self.startNapping(request=request)
 
53
 
54
  elif action == "delete_session":
55
  self.deleteSesorialSession()
 
62
 
63
  return response
64
 
65
+ def startNapping(self, request: HttpRequest):
66
  if request.user.user_presentador.user.username != self.session.creadoPor.user.username:
67
  return self.controllGetResponse(error="Solo el presentador que crea la sesión puede iniciar la repetición", request=request)
68
  elif self.session.activo:
69
  return self.controllGetResponse(error="La sesión ya está activada", request=request)
70
 
71
+ is_update_participations = self.setParticipationsToNoFinished()
72
+ if not is_update_participations:
73
+ return self.controllGetResponse(error="Error al actualizar las participaciones", request=request)
 
 
74
 
75
  self.session.activo = True
76
  self.session.save()
 
81
  return redirect(
82
  reverse(self.url_next, kwargs=parameters))
83
 
84
+ def setDataTable(self):
85
  participations = Participacion.objects.filter(
86
  tecnica=self.session.tecnica).select_related("catador")
87
  testers = [participation.catador for participation in participations]
 
113
  self.context["coordinates_no_mode"] = defaultdict_to_dict(
114
  coordinates_by_product)
115
 
116
+ # Add word frequency data for perfil ultra flash mode
117
+ mod = TecnicaModalidad.objects.get(tecnica=self.session.tecnica)
118
+ if mod.modalidad.nombre == "perfil ultra flash":
119
+ self.setWordFrequencies(ratings)
120
+ elif mod.modalidad.nombre == "sorting":
121
+ self.setSortingData()
122
+
123
  self.context["there_data"] = True
124
 
125
+ def setWordFrequencies(self, ratings):
126
+ from collections import Counter
 
 
127
 
128
+ # Prefetch palabras to optimize queries
129
+ ratings_with_words = ratings.prefetch_related(
130
+ 'palabras').select_related('id_producto')
 
131
 
132
+ # Dictionary to store word frequencies by product
133
+ word_frequencies_by_product = defaultdict(Counter)
134
+ all_words_set = set()
135
+
136
+ for rating in ratings_with_words:
137
+ producto_code = rating.id_producto.codigoProducto
138
+ words = rating.palabras.all()
139
+
140
+ for word in words:
141
+ word_name = word.nombre_palabra
142
+ word_frequencies_by_product[producto_code][word_name] += 1
143
+ all_words_set.add(word_name)
144
+
145
+ # Convert Counter objects to regular dicts and sort words alphabetically
146
+ word_frequencies_dict = {
147
+ product: dict(frequencies)
148
+ for product, frequencies in word_frequencies_by_product.items()
149
+ }
150
+
151
+ # Sort all words alphabetically for consistent column ordering
152
+ all_words_sorted = sorted(all_words_set)
153
+
154
+ self.context["word_frequencies"] = word_frequencies_dict
155
+ self.context["all_words"] = all_words_sorted
156
+
157
+ def setSortingData(self):
158
+ # Get all ratings for this technique to access DatoPunto
159
+ ratings = Calificacion.objects.filter(id_tecnica=self.session.tecnica)
160
+
161
+ # Get coordinates for all products
162
+ coordinates = (
163
+ DatoPunto.objects.filter(calificacion__in=ratings)
164
+ .values(
165
+ producto=F("calificacion__id_producto__codigoProducto"),
166
+ producto_id=F("calificacion__id_producto__id"),
167
+ catador=F("calificacion__id_catador__user__username"),
168
+ catador_id=F("calificacion__id_catador__id"),
169
+ px=F("x"),
170
+ py=F("y"),
171
+ ))
172
+
173
+ # Create a mapping of (catador_id, producto_id) -> coordinates
174
+ coord_map = {}
175
+ for coord in coordinates:
176
+ key = (coord["catador_id"], coord["producto_id"])
177
+ coord_map[key] = {
178
+ "px": coord["px"],
179
+ "py": coord["py"],
180
+ "producto": coord["producto"],
181
+ "catador": coord["catador"]
182
+ }
183
+
184
+ # Get all groups with their products and words
185
+ grupos = (
186
+ GrupoProducto.objects.filter(tecnica=self.session.tecnica)
187
+ .prefetch_related("productos", "palabras")
188
+ .select_related("catador__user")
189
+ )
190
+
191
+ # Create a mapping of (catador_id, producto_id) -> words
192
+ words_map = defaultdict(list)
193
+ for grupo in grupos:
194
+ catador_id = grupo.catador.id
195
+ words = [palabra.nombre_palabra for palabra in grupo.palabras.all()]
196
+ words_str = ";".join(words) if words else ""
197
+
198
+ for producto in grupo.productos.all():
199
+ key = (catador_id, producto.id)
200
+ words_map[key] = words_str
201
+
202
+ # Structure final data: product -> catador -> {px, py, words}
203
+ sorting_data = defaultdict(dict)
204
+
205
+ for key, coord_data in coord_map.items():
206
+ catador_id, producto_id = key
207
+ producto_code = coord_data["producto"]
208
+ catador_username = coord_data["catador"]
209
+
210
+ sorting_data[producto_code][catador_username] = {
211
+ "px": coord_data["px"],
212
+ "py": coord_data["py"],
213
+ "words": words_map.get(key, "")
214
+ }
215
+
216
+ self.context["sorting_data"] = defaultdict_to_dict(sorting_data)
217
+
218
+ def setIsEndSession(self):
219
+ if not self.session.activo and self.session.tecnica.repeticion < 1:
220
+ self.context["finished"] = False
221
+ return
222
+ elif self.session.activo:
223
+ self.context["finished"] = False
224
+ return
225
+ elif not self.session.activo and self.session.tecnica.repeticion >= 1:
226
+ self.context["finished"] = True
227
+ return
tecnicas/controllers/views_controller/session_management/details/details_pf_controller.py CHANGED
@@ -6,6 +6,8 @@ from collections import defaultdict
6
 
7
 
8
  class DetallesPFController(DetallesController):
 
 
9
  def __init__(self, session: SesionSensorial):
10
  super().__init__(session)
11
  self.url_template = "tecnicas/manage_sesions/details-session-pf.html"
@@ -18,7 +20,7 @@ class DetallesPFController(DetallesController):
18
  "sesion": self.session,
19
  "use_technique": technique,
20
  "tipo_escala": technique.escala_tecnica.id_tipo_escala.nombre_escala,
21
- "repeticiones_max": technique.repeticiones_max - 2
22
  }
23
 
24
  # Definir el estado de la sesion
@@ -28,8 +30,15 @@ class DetallesPFController(DetallesController):
28
 
29
  self.getDataPhases()
30
 
 
 
31
  return self.context
32
 
 
 
 
 
 
33
  def getDataPhases(self):
34
  curren_repetition = self.session.tecnica.repeticion
35
 
@@ -46,7 +55,7 @@ class DetallesPFController(DetallesController):
46
  self.context["fisrt_phase"] = self.getDataFirstPhase()
47
  self.context["second_phase"] = self.getDataSecondPhase()
48
  self.context["data_ratings"] = self.getDataRatings()
49
- self.context["repeticion"] = self.session.tecnica.repeticion - 2
50
 
51
  return self.context
52
 
 
6
 
7
 
8
  class DetallesPFController(DetallesController):
9
+ skip_repetition = 2
10
+
11
  def __init__(self, session: SesionSensorial):
12
  super().__init__(session)
13
  self.url_template = "tecnicas/manage_sesions/details-session-pf.html"
 
20
  "sesion": self.session,
21
  "use_technique": technique,
22
  "tipo_escala": technique.escala_tecnica.id_tipo_escala.nombre_escala,
23
+ "repeticiones_max": technique.repeticiones_max - self.skip_repetition
24
  }
25
 
26
  # Definir el estado de la sesion
 
30
 
31
  self.getDataPhases()
32
 
33
+ self.isEndSession()
34
+
35
  return self.context
36
 
37
+ def isEndSession(self):
38
+ current_rep = self.session.tecnica.repeticion - self.skip_repetition
39
+ max_rep = self.session.tecnica.repeticiones_max - self.skip_repetition
40
+ self.context["finished"] = current_rep >= max_rep
41
+
42
  def getDataPhases(self):
43
  curren_repetition = self.session.tecnica.repeticion
44
 
 
55
  self.context["fisrt_phase"] = self.getDataFirstPhase()
56
  self.context["second_phase"] = self.getDataSecondPhase()
57
  self.context["data_ratings"] = self.getDataRatings()
58
+ self.context["repeticion"] = self.session.tecnica.repeticion - self.skip_repetition
59
 
60
  return self.context
61
 
tecnicas/controllers/views_controller/session_management/monitor/monitor_napping_controller.py CHANGED
@@ -27,11 +27,9 @@ class MonitorNappingController(MonitorController):
27
  return (True, "Puedes finalizar la sesión")
28
 
29
  def finishSession(self):
30
- mode_technique = TecnicaModalidad.objects.get(
31
- tecnica=self.sensorial_session.tecnica, usando=True)
32
- mode_technique.usando = False
33
- mode_technique.save()
34
-
35
  self.sensorial_session.activo = False
36
  self.sensorial_session.save()
37
  return self.sensorial_session
 
27
  return (True, "Puedes finalizar la sesión")
28
 
29
  def finishSession(self):
30
+ technique = self.sensorial_session.tecnica
31
+ technique.repeticion = 1
32
+ technique.save()
 
 
33
  self.sensorial_session.activo = False
34
  self.sensorial_session.save()
35
  return self.sensorial_session
tecnicas/controllers/views_controller/sessions_tester/init_session/init_session_napping_controller.py CHANGED
@@ -1,7 +1,7 @@
1
  from django.http import HttpRequest
2
  from django.shortcuts import render, redirect
3
  from django.urls import reverse
4
- from tecnicas.models import Participacion
5
  from tecnicas.controllers import ParticipacionController
6
  from .init_session_controller import InitSessionController
7
 
@@ -19,10 +19,7 @@ class InitSessionNappingController(InitSessionController):
19
  "has_ended": self.isEndedSession()
20
  }
21
 
22
- if self.session.tecnica.repeticion == 1:
23
- self.context["status"] = "En esta sesión se usará Napping"
24
- else:
25
- self.context["status"] = "Se uso Napping puro en la última sesión"
26
 
27
  if "error" in request.GET:
28
  self.context["error"] = request.GET["error"]
@@ -72,3 +69,12 @@ class InitSessionNappingController(InitSessionController):
72
  else:
73
  context["error"] = "Acción sin especificar"
74
  return render(request, self.current_direction, context)
 
 
 
 
 
 
 
 
 
 
1
  from django.http import HttpRequest
2
  from django.shortcuts import render, redirect
3
  from django.urls import reverse
4
+ from tecnicas.models import Participacion, TecnicaModalidad
5
  from tecnicas.controllers import ParticipacionController
6
  from .init_session_controller import InitSessionController
7
 
 
19
  "has_ended": self.isEndedSession()
20
  }
21
 
22
+ self.setStatusSession()
 
 
 
23
 
24
  if "error" in request.GET:
25
  self.context["error"] = request.GET["error"]
 
69
  else:
70
  context["error"] = "Acción sin especificar"
71
  return render(request, self.current_direction, context)
72
+
73
+ def setStatusSession(self):
74
+ technique_mode = TecnicaModalidad.objects.get(
75
+ tecnica=self.session.tecnica).modalidad.nombre
76
+
77
+ if technique_mode == "sin modalidad":
78
+ self.context["status"] = "La sesión usa Napping"
79
+ else:
80
+ self.context["status"] = f"La sesión usa Napping con modalidad {technique_mode}"
tecnicas/controllers/views_controller/sessions_tester/login_session_tester_controller.py CHANGED
@@ -12,6 +12,7 @@ class LoginSessionTesterController():
12
  taster_participation: Participacion
13
  current_direcction = "tecnicas/forms_tester/login_session.html"
14
  destinity_direcction = "cata_system:catador_init_session"
 
15
 
16
  def __init__(self):
17
  self.tester = Catador()
@@ -28,17 +29,17 @@ class LoginSessionTesterController():
28
  return controller_error("Credenciales inválidas")
29
 
30
  def validateEntryEscalas(self, request=HttpRequest):
31
- context = {}
32
  if not self.session.activo:
33
- context["error"] = "La sesión no está activa actualmente"
34
- return render(request, self.current_direcction, context)
35
 
36
  if self.session.tecnica.repeticion == 1:
37
  try:
38
  self.taster_participation = Participacion.objects.get(
39
  tecnica=self.session.tecnica, catador=self.tester)
40
- context["error"] = "Usted ya esta dentro de la sesión"
41
- return render(request, self.current_direcction, context)
42
 
43
  except Participacion.DoesNotExist:
44
  with transaction.atomic():
@@ -51,8 +52,8 @@ class LoginSessionTesterController():
51
  tecnica=self.session.tecnica).count()
52
 
53
  if current_num_testers >= max_testers:
54
- context["error"] = "La sesión ha alcanzado el número máximo de catadores"
55
- return render(request, self.current_direcction, context)
56
 
57
  self.taster_participation = Participacion.objects.create(
58
  tecnica=self.session.tecnica,
@@ -64,21 +65,21 @@ class LoginSessionTesterController():
64
  }
65
  return redirect(reverse(self.destinity_direcction, kwargs=params))
66
  else:
67
- context["error"] = "Ya no es posible ingresar a la sesión"
68
- return render(request, self.current_direcction, context)
69
 
70
  def validateEntryRataCata(self, request: HttpRequest):
71
- context = {}
72
  if not self.session.activo:
73
- context["error"] = "La sesión no está activa actualmente"
74
- return render(request, self.current_direcction, context)
75
 
76
  if self.session.tecnica.repeticion <= 1:
77
  try:
78
  self.taster_participation = Participacion.objects.get(
79
  tecnica=self.session.tecnica, catador=self.tester)
80
- context["error"] = "Usted ya esta dentro de la sesión"
81
- return render(request, self.current_direcction, context)
82
  except Participacion.DoesNotExist:
83
  with transaction.atomic():
84
  code_session = self.session.codigo_sesion
@@ -96,41 +97,41 @@ class LoginSessionTesterController():
96
  }
97
  return redirect(reverse(self.destinity_direcction, kwargs=params))
98
  else:
99
- context["error"] = "Imposible acceder a esta sesión"
100
- return render(request, self.current_direcction, context)
101
 
102
  def validateEntryLimitTesters(self, request: HttpRequest):
103
- context = {}
104
  if not self.session.activo:
105
- context["error"] = "La sesión no está activa actualmente"
106
- return render(request, self.current_direcction, context)
107
 
108
  if self.session.tecnica.repeticion == 1:
109
  return self.entrySessionLimitTesters(request)
110
 
111
  else:
112
- context["error"] = "Ya no es posible ingresar a la sesión"
113
- return render(request, self.current_direcction, context)
114
 
115
  def validateEntryNapping(self, request: HttpRequest):
116
- context = {}
117
  if not self.session.activo:
118
- context["error"] = "La sesión no está activa actualmente"
119
- return render(request, self.current_direcction, context)
120
 
121
  if self.session.tecnica.repeticion == 0:
122
  return self.entrySessionLimitTesters(request)
123
 
124
  else:
125
- context["error"] = "Ya no es posible ingresar a la sesión"
126
- return render(request, self.current_direcction, context)
127
 
128
  def entrySessionLimitTesters(self, request: HttpRequest):
129
  try:
130
  self.taster_participation = Participacion.objects.get(
131
  tecnica=self.session.tecnica, catador=self.tester)
132
- context["error"] = "Usted ya esta dentro de la sesión"
133
- return render(request, self.current_direcction, context)
134
 
135
  except Participacion.DoesNotExist:
136
  try:
@@ -158,5 +159,5 @@ class LoginSessionTesterController():
158
  return redirect(reverse(self.destinity_direcction, kwargs=params))
159
 
160
  except ValueError as e:
161
- context["error"] = str(e)
162
- return render(request, self.current_direcction, context)
 
12
  taster_participation: Participacion
13
  current_direcction = "tecnicas/forms_tester/login_session.html"
14
  destinity_direcction = "cata_system:catador_init_session"
15
+ context = {}
16
 
17
  def __init__(self):
18
  self.tester = Catador()
 
29
  return controller_error("Credenciales inválidas")
30
 
31
  def validateEntryEscalas(self, request=HttpRequest):
32
+ self.context = {}
33
  if not self.session.activo:
34
+ self.context["error"] = "La sesión no está activa actualmente"
35
+ return render(request, self.current_direcction, self.context)
36
 
37
  if self.session.tecnica.repeticion == 1:
38
  try:
39
  self.taster_participation = Participacion.objects.get(
40
  tecnica=self.session.tecnica, catador=self.tester)
41
+ self.context["error"] = "Usted ya esta dentro de la sesión"
42
+ return render(request, self.current_direcction, self.context)
43
 
44
  except Participacion.DoesNotExist:
45
  with transaction.atomic():
 
52
  tecnica=self.session.tecnica).count()
53
 
54
  if current_num_testers >= max_testers:
55
+ self.context["error"] = "La sesión ha alcanzado el número máximo de catadores"
56
+ return render(request, self.current_direcction, self.context)
57
 
58
  self.taster_participation = Participacion.objects.create(
59
  tecnica=self.session.tecnica,
 
65
  }
66
  return redirect(reverse(self.destinity_direcction, kwargs=params))
67
  else:
68
+ self.context["error"] = "Ya no es posible ingresar a la sesión"
69
+ return render(request, self.current_direcction, self.context)
70
 
71
  def validateEntryRataCata(self, request: HttpRequest):
72
+ self.context = {}
73
  if not self.session.activo:
74
+ self.context["error"] = "La sesión no está activa actualmente"
75
+ return render(request, self.current_direcction, self.context)
76
 
77
  if self.session.tecnica.repeticion <= 1:
78
  try:
79
  self.taster_participation = Participacion.objects.get(
80
  tecnica=self.session.tecnica, catador=self.tester)
81
+ self.context["error"] = "Usted ya esta dentro de la sesión"
82
+ return render(request, self.current_direcction, self.context)
83
  except Participacion.DoesNotExist:
84
  with transaction.atomic():
85
  code_session = self.session.codigo_sesion
 
97
  }
98
  return redirect(reverse(self.destinity_direcction, kwargs=params))
99
  else:
100
+ self.context["error"] = "Imposible acceder a esta sesión"
101
+ return render(request, self.current_direcction, self.context)
102
 
103
  def validateEntryLimitTesters(self, request: HttpRequest):
104
+ self.context = {}
105
  if not self.session.activo:
106
+ self.context["error"] = "La sesión no está activa actualmente"
107
+ return render(request, self.current_direcction, self.context)
108
 
109
  if self.session.tecnica.repeticion == 1:
110
  return self.entrySessionLimitTesters(request)
111
 
112
  else:
113
+ self.context["error"] = "Ya no es posible ingresar a la sesión"
114
+ return render(request, self.current_direcction, self.context)
115
 
116
  def validateEntryNapping(self, request: HttpRequest):
117
+ self.context = {}
118
  if not self.session.activo:
119
+ self.context["error"] = "La sesión no está activa actualmente"
120
+ return render(request, self.current_direcction, self.context)
121
 
122
  if self.session.tecnica.repeticion == 0:
123
  return self.entrySessionLimitTesters(request)
124
 
125
  else:
126
+ self.context["error"] = "Ya no es posible ingresar a la sesión"
127
+ return render(request, self.current_direcction, self.context)
128
 
129
  def entrySessionLimitTesters(self, request: HttpRequest):
130
  try:
131
  self.taster_participation = Participacion.objects.get(
132
  tecnica=self.session.tecnica, catador=self.tester)
133
+ self.context["error"] = "Usted ya esta dentro de la sesión"
134
+ return render(request, self.current_direcction, self.context)
135
 
136
  except Participacion.DoesNotExist:
137
  try:
 
159
  return redirect(reverse(self.destinity_direcction, kwargs=params))
160
 
161
  except ValueError as e:
162
+ self.context["error"] = str(e)
163
+ return render(request, self.current_direcction, self.context)
tecnicas/controllers/views_controller/sessions_tester/tests_forms/test_napping_controller.py CHANGED
@@ -2,8 +2,10 @@ from django.http import HttpRequest
2
  from django.shortcuts import redirect, render
3
  from django.urls import reverse
4
  from django.db.models import F
5
- from tecnicas.models import Participacion, Producto, TecnicaModalidad, DatoPunto, Calificacion
 
6
  from tecnicas.utils import noValidTechnique
 
7
  from .general_test_controller import GenetalTestController
8
 
9
 
@@ -11,6 +13,8 @@ class TestNappingController(GenetalTestController):
11
  def __init__(self, sensorial_session, user_tester):
12
  super().__init__(sensorial_session, user_tester)
13
  self.napping_test = "tecnicas/forms_tester/test_napping.html"
 
 
14
 
15
  def controllGet(self, request: HttpRequest):
16
  technique = self.session.tecnica
@@ -26,16 +30,25 @@ class TestNappingController(GenetalTestController):
26
  return redirect(reverse(self.previus_directory, kwargs=params))
27
 
28
  name_mode_activate = TecnicaModalidad.objects.get(
29
- tecnica=technique, usando=True).modalidad.nombre
30
 
31
  if name_mode_activate == "sin modalidad":
32
  self.context["mode"] = "sin modalidad"
33
  return self.nappingTest(request)
 
 
 
 
 
 
 
 
 
34
  else:
35
  return noValidTechnique(
36
  name_view=self.previus_directory,
37
  query_params={
38
- "error": "La técnica no tiene modalidad activada"
39
  },
40
  params={
41
  "code_sesion": self.session.codigo_sesion
@@ -53,6 +66,36 @@ class TestNappingController(GenetalTestController):
53
 
54
  return render(request, self.napping_test, self.context)
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def setCoordinates(self):
57
  technique = self.session.tecnica
58
 
@@ -65,10 +108,135 @@ class TestNappingController(GenetalTestController):
65
  data_points = DatoPunto.objects.filter(
66
  calificacion__in=ratings
67
  ).values(
68
- code= F("calificacion__id_producto__codigoProducto"),
69
- px= F("x"),
70
- py= F("y"),
71
- id_product= F("calificacion__id_producto")
72
  )
73
 
74
  self.context["data_points"] = list(data_points)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from django.shortcuts import redirect, render
3
  from django.urls import reverse
4
  from django.db.models import F
5
+ from tecnicas.models import Participacion, Producto, TecnicaModalidad, DatoPunto, Calificacion, Modalidad, Palabra, GrupoProducto
6
+ from tecnicas.forms import ListWordsForm
7
  from tecnicas.utils import noValidTechnique
8
+ from tecnicas.controllers import ParticipacionController
9
  from .general_test_controller import GenetalTestController
10
 
11
 
 
13
  def __init__(self, sensorial_session, user_tester):
14
  super().__init__(sensorial_session, user_tester)
15
  self.napping_test = "tecnicas/forms_tester/test_napping.html"
16
+ self.napping_puf_test = "tecnicas/forms_tester/test_napping_puf.html"
17
+ self.sort_direction = "tecnicas/forms_tester/test_napping_sort.html"
18
 
19
  def controllGet(self, request: HttpRequest):
20
  technique = self.session.tecnica
 
30
  return redirect(reverse(self.previus_directory, kwargs=params))
31
 
32
  name_mode_activate = TecnicaModalidad.objects.get(
33
+ tecnica=technique).modalidad.nombre
34
 
35
  if name_mode_activate == "sin modalidad":
36
  self.context["mode"] = "sin modalidad"
37
  return self.nappingTest(request)
38
+
39
+ if name_mode_activate == "perfil ultra flash":
40
+ self.context["mode"] = "perfil ultra flash"
41
+ return self.nappingPufTest(request)
42
+
43
+ if name_mode_activate == "sorting":
44
+ self.context["mode"] = "sorting"
45
+ return self.nappingSort(request)
46
+
47
  else:
48
  return noValidTechnique(
49
  name_view=self.previus_directory,
50
  query_params={
51
+ "error": f"Trabajando en la modalidad: {name_mode_activate}"
52
  },
53
  params={
54
  "code_sesion": self.session.codigo_sesion
 
66
 
67
  return render(request, self.napping_test, self.context)
68
 
69
+ def nappingPufTest(self, request: HttpRequest):
70
+ maked_previus_napping = TecnicaModalidad.objects.get(
71
+ tecnica=self.session.tecnica)
72
+
73
+ self.context["maked_napping"] = True if maked_previus_napping else False
74
+ self.context["mode"] = "perfil ultra flash"
75
+ self.context["form"] = ListWordsForm()
76
+
77
+ self.context["session"] = self.session
78
+ technique = self.session.tecnica
79
+ products_in_technique = Producto.objects.filter(id_tecnica=technique)
80
+ self.context["products"] = products_in_technique
81
+ self.setCoordinates()
82
+ self.setWords()
83
+
84
+ return render(request, self.napping_puf_test, self.context)
85
+
86
+ def nappingSort(self, request: HttpRequest):
87
+ self.context["session"] = self.session
88
+ technique = self.session.tecnica
89
+
90
+ products_in_technique = Producto.objects.filter(id_tecnica=technique)
91
+ self.context["products"] = products_in_technique
92
+
93
+ self.context["form"] = ListWordsForm()
94
+ self.setCoordinates()
95
+ self.setGroups()
96
+
97
+ return render(request, self.sort_direction, self.context)
98
+
99
  def setCoordinates(self):
100
  technique = self.session.tecnica
101
 
 
108
  data_points = DatoPunto.objects.filter(
109
  calificacion__in=ratings
110
  ).values(
111
+ code=F("calificacion__id_producto__codigoProducto"),
112
+ px=F("x"),
113
+ py=F("y"),
114
+ id_product=F("calificacion__id_producto__id")
115
  )
116
 
117
  self.context["data_points"] = list(data_points)
118
+
119
+ def setGroups(self):
120
+ technique = self.session.tecnica
121
+
122
+ # Get all product groups for this tester
123
+ grupos_producto = GrupoProducto.objects.filter(
124
+ tecnica=technique,
125
+ catador=self.participation.catador
126
+ ).prefetch_related('productos', 'palabras')
127
+
128
+ groups = []
129
+ for group in grupos_producto:
130
+ # Get products in this group
131
+ products_list = []
132
+ for product in group.productos.all():
133
+ products_list.append({
134
+ 'id': product.id,
135
+ 'codigoProducto': product.codigoProducto
136
+ })
137
+
138
+ # Get words for this group
139
+ words_list = list(group.palabras.values_list(
140
+ 'nombre_palabra', flat=True))
141
+
142
+ groups.append({
143
+ 'id': group.id,
144
+ 'products': products_list,
145
+ 'words': words_list
146
+ })
147
+
148
+ self.context["groups"] = groups
149
+
150
+ def setWords(self):
151
+ technique = self.session.tecnica
152
+
153
+ ratings = Calificacion.objects.filter(
154
+ num_repeticion=0,
155
+ id_tecnica=technique,
156
+ id_catador=self.participation.catador
157
+ ).prefetch_related('palabras', 'id_producto')
158
+
159
+ words_by_product = {}
160
+ for rating in ratings:
161
+ product_code = rating.id_producto.codigoProducto
162
+ words_list = list(rating.palabras.values_list(
163
+ 'nombre_palabra', flat=True))
164
+ if words_list:
165
+ words_by_product[product_code] = words_list
166
+
167
+ self.context["words_by_product"] = words_by_product
168
+
169
+ def controllPost(self, request: HttpRequest):
170
+ action = request.POST.get("action")
171
+
172
+ if action == "finish_session":
173
+ # Get technique and mode
174
+ technique = self.session.tecnica
175
+ self.participation = Participacion.objects.get(
176
+ tecnica=technique, catador=request.user.user_catador)
177
+
178
+ name_mode_activate = TecnicaModalidad.objects.get(
179
+ tecnica=technique).modalidad.nombre
180
+
181
+ # Validate based on mode
182
+ validation_error = self.validateSessionCompletion(
183
+ technique, name_mode_activate)
184
+
185
+ if validation_error:
186
+ # Return to the appropriate template with error
187
+ if name_mode_activate == "sin modalidad":
188
+ return self.nappingTest(request)
189
+ elif name_mode_activate == "perfil ultra flash":
190
+ return self.nappingPufTest(request)
191
+
192
+ # If validation passes, finish the session
193
+ ParticipacionController.finishSession(self.participation)
194
+ params = {"code_sesion": self.session.codigo_sesion}
195
+ return redirect(reverse(self.previus_directory, kwargs=params))
196
+
197
+ # For other actions, call parent's controllPost
198
+ return super().controllPost(request)
199
+
200
+ def validateSessionCompletion(self, technique, mode_name):
201
+ # Get all products in technique
202
+ products = Producto.objects.filter(id_tecnica=technique)
203
+ product_count = products.count()
204
+
205
+ # Get all ratings for this tester
206
+ ratings = Calificacion.objects.filter(
207
+ num_repeticion=0,
208
+ id_tecnica=technique,
209
+ id_catador=self.participation.catador
210
+ ).select_related('id_producto').prefetch_related('palabras')
211
+
212
+ # Check if all products have ratings
213
+ if ratings.count() != product_count:
214
+ missing_count = product_count - ratings.count()
215
+ return f"Faltan {missing_count} producto(s) por evaluar."
216
+
217
+ # Check if all ratings have DatoPunto (coordinates)
218
+ ratings_with_points = DatoPunto.objects.filter(
219
+ calificacion__in=ratings
220
+ ).values_list('calificacion_id', flat=True)
221
+
222
+ ratings_without_points = ratings.exclude(id__in=ratings_with_points)
223
+ if ratings_without_points.exists():
224
+ missing_products = [
225
+ r.id_producto.codigoProducto for r in ratings_without_points
226
+ ]
227
+ return f"Los siguientes productos no tienen coordenadas: {', '.join(missing_products)}"
228
+
229
+ # Additional validation for "perfil ultra flash" mode
230
+ if mode_name == "perfil ultra flash":
231
+ # Check that each rating has at least one word
232
+ ratings_without_words = []
233
+ for rating in ratings:
234
+ if rating.palabras.count() < 1:
235
+ ratings_without_words.append(
236
+ rating.id_producto.codigoProducto)
237
+
238
+ if ratings_without_words:
239
+ return f"Los siguientes productos deben tener al menos 1 palabra: {', '.join(ratings_without_words)}"
240
+
241
+ # All validations passed
242
+ return None
tecnicas/controllers/views_controller/sessions_tester/tests_forms/test_sort_controller.py CHANGED
@@ -40,11 +40,10 @@ class TestSortController(GenetalTestController):
40
  self.context["products"] = products_in_technique
41
 
42
  grups_products = GrupoProducto.objects.filter(
43
- tecnica=technique, catador=request.user.user_catador).select_related("productos", "palabras")
44
 
45
- self.context["grups_products"] = grups_products if grups_products else []
46
 
47
  self.context["form_word"] = ListWordsForm()
48
 
49
  return render(request, self.current_directory, self.context)
50
-
 
40
  self.context["products"] = products_in_technique
41
 
42
  grups_products = GrupoProducto.objects.filter(
43
+ tecnica=technique, catador=request.user.user_catador)
44
 
45
+ self.context["grups_products"] = grups_products or []
46
 
47
  self.context["form_word"] = ListWordsForm()
48
 
49
  return render(request, self.current_directory, self.context)
 
tecnicas/forms/create_session/sesion_basic_napping.py CHANGED
@@ -1,4 +1,5 @@
1
  from django import forms
 
2
 
3
 
4
  class SesionBasicNappingForm(forms.Form):
@@ -22,3 +23,16 @@ class SesionBasicNappingForm(forms.Form):
22
  "class": "bg-surface-ligt border-b-1 text-center w-full p-1",
23
  "placeholder": "Este campo es opcional"
24
  }), required=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from django import forms
2
+ from tecnicas.models import Modalidad
3
 
4
 
5
  class SesionBasicNappingForm(forms.Form):
 
23
  "class": "bg-surface-ligt border-b-1 text-center w-full p-1",
24
  "placeholder": "Este campo es opcional"
25
  }), required=False)
26
+
27
+ def __init__(self, *args, **kwargs):
28
+ super(SesionBasicNappingForm, self).__init__(*args, **kwargs)
29
+ names_mod = [
30
+ ("sin modalidad", "sin modalidad"),
31
+ ("sorting", "sorting"),
32
+ ("perfil ultra flash", "perfil ultra flash")
33
+ ]
34
+
35
+ self.fields['modalidad'] = forms.CharField(widget=forms.RadioSelect(choices=names_mod, attrs={
36
+ "class": "radio radio-lg radio-info",
37
+ "placeholder": "Seleccione una modalidad",
38
+ }), required=True, initial=names_mod[0])
tecnicas/static/js/span-notification.js CHANGED
@@ -1,5 +1,6 @@
1
  function spanNotifaction(messageError, isError = true, time = 4500) {
2
  const span = document.createElement("span");
 
3
  span.textContent = messageError;
4
 
5
  const div = document.createElement("div");
 
1
  function spanNotifaction(messageError, isError = true, time = 4500) {
2
  const span = document.createElement("span");
3
+ span.classList.add("text-xl", "font-bold", "text-white");
4
  span.textContent = messageError;
5
 
6
  const div = document.createElement("div");
tecnicas/static/js/test-napping-plane.js ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const planeContainer = document.getElementById('napping-plane');
2
+ const productsContainer = document.getElementById('items');
3
+ const products = document.querySelectorAll('.item-product');
4
+
5
+ // Configuration for the physical dimensions of the tablecloth (in cm)
6
+ const PHYSICAL_WIDTH = 60;
7
+ const PHYSICAL_HEIGHT = 40;
8
+
9
+ let selectedProductCode = null;
10
+ let selectedProductId = null;
11
+
12
+ // Object to store coordinates: { "CODE": { x: 10.5, y: 20.1, id: 123 } }
13
+ window.placedPoints = {};
14
+
15
+ const modeElement = document.querySelector('[data-mode]');
16
+ window.isUltraFlash = modeElement && modeElement.dataset.mode.toLowerCase().includes('ultra flash');
17
+
18
+ // For ultra flash mode, it starts active but will be controlled by ultra-flash.js
19
+ if (window.isUltraFlash) {
20
+ document.getElementById("question-save").classList.add("hidden");
21
+ window.isPlacementActive = true;
22
+ } else {
23
+ // Normal mode: placement is always active
24
+ window.isPlacementActive = true;
25
+ }
26
+
27
+ // 1. Handle Product Selection
28
+ products.forEach(product => {
29
+ product.addEventListener('click', () => {
30
+ if (!window.isPlacementActive) return;
31
+
32
+ // Remove selection from others
33
+ products.forEach(p => p.classList.remove('ring-4', 'ring-primary'));
34
+
35
+ // Select current
36
+ product.classList.add('ring-4', 'ring-primary');
37
+ selectedProductCode = product.dataset.code;
38
+ selectedProductId = product.dataset.idProduct;
39
+ });
40
+ });
41
+
42
+ // 2. Handle Plane Click (Placing Points)
43
+ planeContainer.addEventListener('click', (e) => {
44
+ if (!window.isPlacementActive) return;
45
+
46
+ if (!selectedProductCode) {
47
+ spanNotifaction("Por favor, selecciona un producto primero")
48
+ return;
49
+ }
50
+
51
+ const rect = planeContainer.getBoundingClientRect();
52
+
53
+ // Calculate click position relative to the container (in pixels)
54
+ const xPixel = e.clientX - rect.left;
55
+ const yPixel = e.clientY - rect.top;
56
+
57
+ // Calculate scaled coordinates (0 to PHYSICAL_DIMENSIONS)
58
+ // X axis: 0 on left, 60 on right
59
+ const xCoord = (xPixel / rect.width) * PHYSICAL_WIDTH;
60
+
61
+ // Y axis: 0 on bottom, 40 on top (Invert Y because DOM Y is 0 at top)
62
+ const yCoord = PHYSICAL_HEIGHT - ((yPixel / rect.height) * PHYSICAL_HEIGHT);
63
+
64
+ // Update Data Object
65
+ window.placedPoints[selectedProductCode] = {
66
+ x: parseFloat(xCoord.toFixed(2)),
67
+ y: parseFloat(yCoord.toFixed(2)),
68
+ id: selectedProductId
69
+ };
70
+
71
+ // Render Point
72
+ window.renderPoint(selectedProductCode, xPixel, yPixel, xCoord, yCoord);
73
+ });
74
+
75
+ // Make renderPoint global
76
+ window.renderPoint = function (code, xPx, yPx, xVal, yVal) {
77
+ // Remove existing point for this product if it exists
78
+ const existingPoint = document.getElementById(`point-${code}`);
79
+ if (existingPoint) {
80
+ existingPoint.remove();
81
+ }
82
+
83
+ const point = document.createElement('div');
84
+ point.id = `point-${code}`;
85
+ point.className = 'data-point absolute w-4 h-4 bg-red-600 rounded-full transform -translate-x-1/2 -translate-y-1/2 cursor-pointer border-2 border-white shadow-md group';
86
+ point.dataset.code = code;
87
+ point.dataset.px = xVal;
88
+ point.dataset.py = yVal;
89
+ point.dataset.idProduct = window.placedPoints[code]?.id;
90
+
91
+ point.style.left = `${xPx}px`;
92
+ point.style.top = `${yPx}px`;
93
+
94
+ planeContainer.appendChild(point);
95
+
96
+ const textLabel = document.createElement('span');
97
+ textLabel.className = 'absolute top-4 left-1/2 transform -translate-x-1/2 text-xs font-bold text-gray-700 pointer-events-none';
98
+ textLabel.innerText = code;
99
+ point.appendChild(textLabel);
100
+ }
101
+
102
+ function checkPoints() {
103
+ const points = document.querySelectorAll('.data-point');
104
+
105
+ points.forEach(point => {
106
+ const code = point.dataset.code;
107
+
108
+ const xVal = parseFloat(point.dataset.px);
109
+ const yVal = parseFloat(point.dataset.py);
110
+
111
+ const rect = planeContainer.getBoundingClientRect();
112
+
113
+ const px = (xVal / PHYSICAL_WIDTH) * rect.width;
114
+ const py = ((PHYSICAL_HEIGHT - yVal) / PHYSICAL_HEIGHT) * rect.height;
115
+
116
+ window.placedPoints[code] = {
117
+ x: xVal.toFixed(2),
118
+ y: yVal.toFixed(2),
119
+ id: point.dataset.idProduct
120
+ };
121
+
122
+ window.renderPoint(code, px, py, xVal, yVal);
123
+ });
124
+ }
125
+
126
+ setTimeout(checkPoints, 100);
127
+
128
+ /*
129
+ ////
130
+ //////
131
+ //////// Question to finish session
132
+ //////
133
+ ////
134
+ */
135
+
136
+ function showOptionsSave() {
137
+ document.getElementById("question-save").classList.add("hidden");
138
+ document.getElementById("finish-session").classList.remove("hidden");
139
+ document.getElementById("cancel-save").classList.remove("hidden");
140
+ }
141
+
142
+ function showQuestionSave() {
143
+ document.getElementById("question-save").classList.remove("hidden");
144
+ document.getElementById("finish-session").classList.add("hidden");
145
+ document.getElementById("cancel-save").classList.add("hidden");
146
+ }
147
+
148
+ document
149
+ .getElementById("question-save")
150
+ .addEventListener("click", showOptionsSave);
151
+
152
+ document
153
+ .getElementById("cancel-save")
154
+ .addEventListener("click", showQuestionSave);
155
+
156
+ /*
157
+ ////
158
+ //////
159
+ //////// Save data and finish session
160
+ //////
161
+ ////
162
+ */
163
+
164
+ // Callback for additional validation before saving
165
+ window.beforeSaveData = null;
166
+
167
+ // Function to get extra data for each product
168
+ window.getExtraDataForSave = null;
169
+
170
+ window.saveData = async function (isFinishSession = false) {
171
+ const codeProducts = Object.keys(window.placedPoints);
172
+ const data = [];
173
+
174
+ // Only validate all products placed if finishing session
175
+ if (isFinishSession && products.length != codeProducts.length) {
176
+ spanNotifaction("Por favor, coloca todos los puntos antes de finalizar la sesión")
177
+ return false;
178
+ }
179
+
180
+ // Call beforeSaveData callback if it exists (for ultra flash validation)
181
+ if (window.beforeSaveData && typeof window.beforeSaveData === 'function') {
182
+ const validationResult = window.beforeSaveData(isFinishSession);
183
+ if (validationResult === false) {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ codeProducts.forEach((code) => {
189
+ const point = window.placedPoints[code];
190
+
191
+ const objData = {
192
+ code: code,
193
+ x: point.x,
194
+ y: point.y,
195
+ idProduct: point.id
196
+ };
197
+
198
+ // Get extra data if callback exists (for ultra flash words)
199
+ if (window.getExtraDataForSave && typeof window.getExtraDataForSave === 'function') {
200
+ const extraData = window.getExtraDataForSave(code);
201
+ Object.assign(objData, extraData);
202
+ }
203
+
204
+ data.push(objData);
205
+ })
206
+
207
+ const URL = "/cata/testers/api/rating-napping"
208
+ const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
209
+
210
+ try {
211
+ const response = await fetch(URL, {
212
+ method: "POST",
213
+ headers: {
214
+ "Content-Type": "application/json",
215
+ "X-CSRFToken": csrfToken,
216
+ },
217
+ body: JSON.stringify(data),
218
+ })
219
+
220
+ if (!response.ok) {
221
+ spanNotifaction("Error en la respuesta del servidor")
222
+ return false;
223
+ }
224
+
225
+ const result = await response.json()
226
+
227
+ if (result.error) {
228
+ spanNotifaction(result.error)
229
+ return false
230
+ } else {
231
+ spanNotifaction(result.message, false)
232
+ return true
233
+ }
234
+ } catch (error) {
235
+ spanNotifaction("Error en proceso de guardar los datos")
236
+ return false
237
+ }
238
+ }
239
+
240
+ // Function to finish session with validation
241
+ window.finishSession = async function () {
242
+ const success = await window.saveData(true);
243
+ if (success) {
244
+ // Submit the form to finish session
245
+ const formFinish = document.getElementById("form-finish-session")
246
+ formFinish.action = ""
247
+ formFinish.submit();
248
+ }
249
+ }
250
+
251
+ document
252
+ .getElementById("save-progress")
253
+ .addEventListener("click", () => window.saveData(false));
tecnicas/static/js/test-napping-sort.js ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Napping Sort Mode - Three Phase Implementation
2
+ // Phase 1: Place products on plane
3
+ // Phase 2: Create groups by selecting points
4
+ // Phase 3: Describe groups with words
5
+
6
+ // Store groups: { "group-1": ["CODE1", "CODE2"], "group-2": ["CODE3"] }
7
+ const productGroups = {};
8
+
9
+ // Store group words: { "group-1": ["word1", "word2"], "group-2": ["word3"] }
10
+ const groupWords = {};
11
+
12
+ // Track which group each product belongs to: { "CODE1": "group-1", "CODE2": "group-1" }
13
+ const productToGroup = {};
14
+
15
+ // Selected points for creating a group
16
+ let selectedPoints = [];
17
+
18
+ // Current group counter for generating IDs
19
+ let groupCounter = 1;
20
+
21
+ // Current phase (1, 2, or 3)
22
+ let currentPhase = 1;
23
+
24
+ // Current group being described
25
+ let currentGroupId = null;
26
+
27
+ // Only initialize if in sort mode
28
+ const modeElementSort = document.querySelector('[data-mode]');
29
+ const isSortMode = modeElementSort && modeElementSort.dataset.mode.toLowerCase() === 'sorting';
30
+
31
+ if (isSortMode) {
32
+ initSortMode();
33
+ }
34
+
35
+ function loadExistingGroups() {
36
+ const dataGroupContainer = document.querySelector('.data-group-products');
37
+ if (!dataGroupContainer) return { hasGroups: false, hasWords: false };
38
+
39
+ const groupElements = dataGroupContainer.querySelectorAll('.item-group');
40
+ let hasGroups = false;
41
+ let hasWords = false;
42
+
43
+ groupElements.forEach((groupEl, index) => {
44
+ const groupId = `group-${groupCounter++}`;
45
+ const products = [];
46
+ const words = groupEl.dataset.words ? groupEl.dataset.words.split(',').map(w => w.trim()).filter(w => w) : [];
47
+
48
+ // Get products in this group
49
+ groupEl.querySelectorAll('.item-group-product').forEach(productEl => {
50
+ const code = productEl.dataset.code;
51
+ products.push(code);
52
+ productToGroup[code] = groupId;
53
+ });
54
+
55
+ if (products.length > 0) {
56
+ hasGroups = true;
57
+ productGroups[groupId] = products;
58
+ groupWords[groupId] = words;
59
+
60
+ if (words.length > 0) {
61
+ hasWords = true;
62
+ }
63
+ }
64
+ });
65
+
66
+ return { hasGroups, hasWords };
67
+ }
68
+
69
+ function initSortMode() {
70
+ const continueGroupingBtn = document.getElementById('continue-grouping');
71
+ const continueDescriptionBtn = document.getElementById('continue-description');
72
+ const createGroupBtn = document.getElementById('create-group-btn');
73
+ const dissolveGroupBtn = document.getElementById('dissolve-group-btn');
74
+ const groupControls = document.getElementById('group-controls');
75
+ const questionSaveBtn = document.getElementById('question-save');
76
+
77
+ const groupWordDialog = document.getElementById('group-word-dialog');
78
+ const groupWordForm = document.getElementById('group-word-form');
79
+ const groupWordInput = document.getElementsByName('nombre_palabra')[0];
80
+ groupWordInput.value = "";
81
+ const groupWordList = document.getElementById('group-word-list');
82
+ const dialogGroupId = document.getElementById('dialog-group-id');
83
+
84
+ // Hide question save button initially
85
+ questionSaveBtn.classList.add('hidden');
86
+
87
+ // Load existing groups from backend and determine initial phase
88
+ const { hasGroups, hasWords } = loadExistingGroups();
89
+
90
+ setTimeout(() => {
91
+ // Determine initial phase based on existing data
92
+ if (hasGroups && hasWords) {
93
+ // Skip to Phase 3 (Description) if groups have words
94
+ currentPhase = 3;
95
+ window.isPlacementActive = false;
96
+ groupControls.classList.remove('hidden');
97
+ continueDescriptionBtn.classList.remove('hidden');
98
+
99
+ renderExistingGroups();
100
+ startDescriptionPhase();
101
+ } else if (hasGroups) {
102
+ // Skip to Phase 2 (Grouping) if groups exist but no words
103
+ currentPhase = 2;
104
+ renderExistingGroups();
105
+ window.isPlacementActive = false;
106
+ groupControls.classList.remove('hidden');
107
+ continueDescriptionBtn.classList.remove('hidden');
108
+
109
+ const plane = document.getElementById('napping-plane');
110
+ plane.classList.remove('cursor-crosshair');
111
+ plane.classList.add('cursor-default');
112
+
113
+ enablePointSelection();
114
+ spanNotifaction("Continúa agrupando productos o pasa a la descripción.", false);
115
+ } else {
116
+ // Start in Phase 1 (Placement)
117
+ currentPhase = 1;
118
+ }
119
+ }, 200);
120
+
121
+ // Phase 1: Product Placement
122
+ // Show continue to grouping button when all products are placed
123
+ setInterval(() => {
124
+ if (currentPhase === 1) {
125
+ const placedCount = Object.keys(window.placedPoints).length;
126
+ const totalProducts = document.querySelectorAll('.item-product').length;
127
+
128
+ if (placedCount === totalProducts && placedCount > 0) {
129
+ continueGroupingBtn.classList.remove('hidden');
130
+ } else {
131
+ continueGroupingBtn.classList.add('hidden');
132
+ }
133
+ }
134
+ }, 500);
135
+
136
+ // Transition to Phase 2: Grouping
137
+ continueGroupingBtn.addEventListener('click', () => {
138
+ const placedCount = Object.keys(window.placedPoints).length;
139
+ const totalProducts = document.querySelectorAll('.item-product').length;
140
+
141
+ if (placedCount !== totalProducts) {
142
+ spanNotifaction("Por favor, coloca todos los productos antes de continuar.");
143
+ return;
144
+ }
145
+
146
+ startGroupingPhase();
147
+ });
148
+
149
+ function startGroupingPhase() {
150
+ currentPhase = 2;
151
+ window.isPlacementActive = false;
152
+ continueGroupingBtn.classList.add('hidden');
153
+ groupControls.classList.remove('hidden');
154
+ continueDescriptionBtn.classList.remove('hidden');
155
+
156
+ const plane = document.getElementById('napping-plane');
157
+ plane.classList.remove('cursor-crosshair');
158
+ plane.classList.add('cursor-default');
159
+
160
+ // Remove selection from products
161
+ document.querySelectorAll('.item-product').forEach(p => {
162
+ p.classList.remove('ring-4', 'ring-primary');
163
+ });
164
+
165
+ spanNotifaction("Fase de agrupación: Selecciona puntos y crea grupos.", false);
166
+
167
+ // Auto-save points when transitioning to Phase 2
168
+ sortModeSaveData(false);
169
+
170
+ // Enable point selection
171
+ enablePointSelection();
172
+ }
173
+
174
+ function enablePointSelection() {
175
+ // Add click handler to points for selection
176
+ document.getElementById('napping-plane').addEventListener('click', (e) => {
177
+ if (currentPhase !== 2) return;
178
+
179
+ const point = e.target.closest('.data-point');
180
+ if (point) {
181
+ e.stopPropagation();
182
+ togglePointSelection(point.dataset.code);
183
+ }
184
+ });
185
+ }
186
+
187
+ function togglePointSelection(code) {
188
+ // Check if point is already in a group
189
+ if (productToGroup[code]) {
190
+ spanNotifaction(`El producto ${code} ya pertenece al grupo ${productToGroup[code]}`);
191
+ return;
192
+ }
193
+
194
+ const point = document.getElementById(`point-${code}`);
195
+ const index = selectedPoints.indexOf(code);
196
+
197
+ if (index > -1) {
198
+ // Deselect
199
+ selectedPoints.splice(index, 1);
200
+ point.classList.remove('ring-4', 'ring-blue-500');
201
+ point.classList.add('bg-red-600');
202
+ point.classList.remove('bg-blue-600');
203
+ } else {
204
+ // Select
205
+ selectedPoints.push(code);
206
+ point.classList.add('ring-4', 'ring-blue-500');
207
+ point.classList.remove('bg-red-600');
208
+ point.classList.add('bg-blue-600');
209
+ }
210
+ }
211
+
212
+ // Create Group
213
+ createGroupBtn.addEventListener('click', () => {
214
+ if (selectedPoints.length === 0) {
215
+ spanNotifaction("Selecciona al menos un punto para crear un grupo");
216
+ return;
217
+ }
218
+
219
+ const groupId = `group-${groupCounter++}`;
220
+ productGroups[groupId] = [...selectedPoints];
221
+ groupWords[groupId] = [];
222
+
223
+ // Update product to group mapping
224
+ selectedPoints.forEach(code => {
225
+ productToGroup[code] = groupId;
226
+ });
227
+
228
+ // Visual update: change color of grouped points
229
+ const colors = ['bg-green-600', 'bg-purple-600', 'bg-yellow-600', 'bg-pink-600', 'bg-indigo-600'];
230
+ const colorIndex = (groupCounter - 2) % colors.length;
231
+
232
+ selectedPoints.forEach(code => {
233
+ const point = document.getElementById(`point-${code}`);
234
+ point.classList.remove('bg-blue-600', 'ring-4', 'ring-blue-500');
235
+ point.classList.add(colors[colorIndex]);
236
+ });
237
+
238
+ // Add group to display
239
+ addGroupToDisplay(groupId, selectedPoints, colors[colorIndex]);
240
+
241
+ // Clear selection
242
+ selectedPoints = [];
243
+
244
+ spanNotifaction(`Grupo "${groupId}" creado con éxito`, false);
245
+ });
246
+
247
+ function addGroupToDisplay(groupId, products, colorClass) {
248
+ const groupsDisplay = document.getElementById('groups-display');
249
+
250
+ const groupBadge = document.createElement('div');
251
+ groupBadge.id = `display-${groupId}`;
252
+ groupBadge.className = `badge badge-lg gap-2 p-4 ${colorClass} text-white cursor-pointer font-semibold`;
253
+ groupBadge.innerHTML = `
254
+ [ ${products.join(', ')} ]
255
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current dissolve-group-icon" data-group-id="${groupId}">
256
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
257
+ </svg>
258
+ `;
259
+
260
+ groupsDisplay.appendChild(groupBadge);
261
+
262
+ // Add dissolve handler
263
+ groupBadge.querySelector('.dissolve-group-icon').addEventListener('click', (e) => {
264
+ e.stopPropagation();
265
+ dissolveGroup(groupId);
266
+ });
267
+
268
+ // Add click handler for Phase 3 (describe group)
269
+ groupBadge.addEventListener('click', () => {
270
+ if (currentPhase === 3) {
271
+ openGroupWordDialog(groupId);
272
+ }
273
+ });
274
+ }
275
+
276
+ function dissolveGroup(groupId) {
277
+ if (currentPhase === 3) {
278
+ spanNotifaction("No puedes disolver un grupo en la fase de descripción");
279
+ return;
280
+ }
281
+
282
+ if (groupWords[groupId] && groupWords[groupId].length > 0) {
283
+ spanNotifaction("No puedes disolver un grupo que ya tiene palabras descriptivas");
284
+ return;
285
+ }
286
+
287
+ // Remove from product to group mapping
288
+ productGroups[groupId].forEach(code => {
289
+ delete productToGroup[code];
290
+ const point = document.getElementById(`point-${code}`);
291
+ point.classList.remove('bg-green-600', 'bg-purple-600', 'bg-yellow-600', 'bg-pink-600', 'bg-indigo-600');
292
+ point.classList.add('bg-red-600');
293
+ });
294
+
295
+ // Remove group
296
+ delete productGroups[groupId];
297
+ delete groupWords[groupId];
298
+
299
+ // Remove from display
300
+ const displayElement = document.getElementById(`display-${groupId}`);
301
+ if (displayElement) {
302
+ displayElement.remove();
303
+ }
304
+
305
+ spanNotifaction(`Grupo "${groupId}" disuelto`, false);
306
+ }
307
+
308
+ // Dissolve Group button (dissolves last created group or selected group)
309
+ dissolveGroupBtn.addEventListener('click', () => {
310
+ const groupIds = Object.keys(productGroups);
311
+ if (groupIds.length === 0) {
312
+ spanNotifaction("No hay grupos para disolver");
313
+ return;
314
+ }
315
+
316
+ // Dissolve the last group
317
+ const lastGroupId = groupIds[groupIds.length - 1];
318
+ dissolveGroup(lastGroupId);
319
+ });
320
+
321
+ // Transition to Phase 3: Description
322
+ continueDescriptionBtn.addEventListener('click', () => {
323
+ const groupIds = Object.keys(productGroups);
324
+ if (groupIds.length === 0) {
325
+ spanNotifaction("Crea al menos un grupo para continuar");
326
+ return;
327
+ }
328
+
329
+ // Check all products are in groups
330
+ const totalProducts = document.querySelectorAll('.item-product').length;
331
+ const groupedProducts = Object.keys(productToGroup).length;
332
+
333
+ if (groupedProducts !== totalProducts) {
334
+ spanNotifaction("Todos los productos deben estar asignados a un grupo");
335
+ return;
336
+ }
337
+
338
+ startDescriptionPhase();
339
+ });
340
+
341
+ function startDescriptionPhase() {
342
+ currentPhase = 3;
343
+ continueDescriptionBtn.classList.add('hidden');
344
+
345
+ createGroupBtn.classList.add('hidden');
346
+ dissolveGroupBtn.classList.add('hidden');
347
+
348
+ questionSaveBtn.classList.remove('hidden');
349
+
350
+ const iconsDisolveGroup = document.querySelectorAll('.dissolve-group-icon');
351
+ for (let index = 0; index < iconsDisolveGroup.length; index++) {
352
+ const icon = iconsDisolveGroup.item(index);
353
+ icon.remove();
354
+ }
355
+
356
+ spanNotifaction("Fase de descripción: Haz clic en un grupo para agregar palabras.", false);
357
+
358
+ // Auto-save groups when transitioning to Phase 3
359
+ sortModeSaveData(false);
360
+ }
361
+
362
+ // Group Word Dialog Functions
363
+ function openGroupWordDialog(groupId) {
364
+ currentGroupId = groupId;
365
+ dialogGroupId.innerText = groupId;
366
+ renderGroupWordList();
367
+ groupWordDialog.showModal();
368
+ }
369
+
370
+ function renderGroupWordList() {
371
+ groupWordList.innerHTML = '';
372
+ const words = groupWords[currentGroupId] || [];
373
+
374
+ words.forEach((word, index) => {
375
+ const badge = document.createElement('div');
376
+ badge.className = 'badge badge-secondary gap-2 p-3';
377
+ badge.innerHTML = `
378
+ ${word}
379
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current cursor-pointer remove-word" data-index="${index}"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
380
+ `;
381
+
382
+ badge.querySelector('.remove-word').addEventListener('click', () => {
383
+ removeGroupWord(index);
384
+ });
385
+
386
+ groupWordList.appendChild(badge);
387
+
388
+ });
389
+
390
+ // Update group display with word count
391
+ updateGroupDisplayWithWords(currentGroupId);
392
+ }
393
+
394
+ function addGroupWord(word) {
395
+ if (!groupWords[currentGroupId]) {
396
+ groupWords[currentGroupId] = [];
397
+ }
398
+
399
+ if (groupWords[currentGroupId].includes(word)) {
400
+ spanNotifaction("Palabra duplicada");
401
+ return;
402
+ }
403
+
404
+ groupWords[currentGroupId].push(word);
405
+ renderGroupWordList();
406
+ }
407
+
408
+ function removeGroupWord(index) {
409
+ if (groupWords[currentGroupId]) {
410
+ groupWords[currentGroupId].splice(index, 1);
411
+ renderGroupWordList();
412
+ }
413
+ }
414
+
415
+ groupWordForm.addEventListener('submit', (e) => {
416
+ e.preventDefault();
417
+ const word = groupWordInput.value.trim();
418
+ if (word) {
419
+ addGroupWord(word);
420
+ groupWordInput.value = '';
421
+ groupWordInput.focus();
422
+ }
423
+ });
424
+
425
+ function updateGroupDisplayWithWords(groupId) {
426
+ const displayElement = document.getElementById(`display-${groupId}`);
427
+ if (!displayElement) return;
428
+
429
+ const words = groupWords[groupId] || [];
430
+ const products = productGroups[groupId] || [];
431
+
432
+ // Add tooltip with words on hover
433
+ if (words.length > 0) {
434
+ let tooltip = displayElement.querySelector('.group-tooltip');
435
+ if (!tooltip) {
436
+ tooltip = document.createElement('div');
437
+ tooltip.className = 'group-tooltip absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 text-white text-xs rounded z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none hidden';
438
+ displayElement.classList.add('group', 'relative');
439
+ displayElement.appendChild(tooltip);
440
+ }
441
+
442
+ const wordBadges = words.map(w => `<span class="inline-block px-2 py-1 bg-yellow-600 text-white rounded text-xs">${w}</span>`).join('');
443
+ tooltip.innerHTML = `
444
+ <strong>${groupId}</strong>
445
+ <div class="mt-2 pt-2 border-t border-gray-600" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 300px;">
446
+ ${wordBadges}
447
+ </div>
448
+ `;
449
+ tooltip.style.maxWidth = '320px';
450
+ tooltip.style.whiteSpace = 'normal';
451
+ tooltip.classList.remove('hidden');
452
+ } else {
453
+ let tooltip = displayElement.querySelector('.group-tooltip');
454
+ if (tooltip) {
455
+ tooltip.remove();
456
+ }
457
+ }
458
+ }
459
+
460
+ function renderExistingGroups() {
461
+ const colors = ['bg-green-600', 'bg-purple-600', 'bg-yellow-600', 'bg-pink-600', 'bg-indigo-600'];
462
+ let colorIndex = 0;
463
+
464
+ for (const [groupId, products] of Object.entries(productGroups)) {
465
+ const color = colors[colorIndex % colors.length];
466
+ colorIndex++;
467
+
468
+ // Update point colors
469
+ products.forEach(code => {
470
+ const point = document.getElementById(`point-${code}`);
471
+
472
+ if (point) {
473
+ point.classList.remove('bg-red-600');
474
+ point.classList.add(color);
475
+ }
476
+ });
477
+
478
+ // Add group to display
479
+ addGroupToDisplay(groupId, products, color);
480
+
481
+ // Update display with words if they exist
482
+ if (groupWords[groupId] && groupWords[groupId].length > 0) {
483
+ updateGroupDisplayWithWords(groupId);
484
+ }
485
+ }
486
+ }
487
+
488
+ // Set up callbacks to extend the base saveData function
489
+ window.beforeSaveData = function (isFinishSession = false) {
490
+ if (isFinishSession) {
491
+ // Validate all products are placed
492
+ const totalProducts = document.querySelectorAll('.item-product').length;
493
+ const placedCount = Object.keys(window.placedPoints).length;
494
+
495
+ if (placedCount !== totalProducts) {
496
+ spanNotifaction("Por favor, coloca todos los productos antes de finalizar la sesión");
497
+ return false;
498
+ }
499
+
500
+ // Validate all products are in groups
501
+ const groupedProducts = Object.keys(productToGroup).length;
502
+ if (groupedProducts !== totalProducts) {
503
+ spanNotifaction("Todos los productos deben estar asignados a un grupo");
504
+ return false;
505
+ }
506
+
507
+ // Validate each group has at least one product (already guaranteed by creation logic)
508
+ const groupIds = Object.keys(productGroups);
509
+ if (groupIds.length === 0) {
510
+ spanNotifaction("Debe existir al menos un grupo");
511
+ return false;
512
+ }
513
+
514
+ // Validate each group has at least one word
515
+ for (const groupId of groupIds) {
516
+ const words = groupWords[groupId] || [];
517
+ if (words.length < 1) {
518
+ spanNotifaction(`El grupo ${groupId} debe tener al menos una palabra descriptiva`);
519
+ return false;
520
+ }
521
+ }
522
+ }
523
+ return true;
524
+ };
525
+
526
+ // Override save-progress button to use sort mode save function
527
+ const saveProgressBtn = document.getElementById('save-progress');
528
+ // Remove existing event listener by cloning and replacing
529
+ const newSaveProgressBtn = saveProgressBtn.cloneNode(true);
530
+ newSaveProgressBtn.textContent = 'Guardar Progreso Sort';
531
+ saveProgressBtn.parentNode.replaceChild(newSaveProgressBtn, saveProgressBtn);
532
+
533
+ newSaveProgressBtn.addEventListener('click', async () => {
534
+ await sortModeSaveData(false);
535
+ });
536
+
537
+ // Override finish-session button to use sort mode save function
538
+ document.getElementById('finish-session').addEventListener('click', async (e) => {
539
+ e.preventDefault();
540
+ e.stopPropagation();
541
+ const success = await sortModeSaveData(true);
542
+ if (success) {
543
+ const formFinish = document.getElementById("form-finish-session");
544
+ formFinish.action = "";
545
+ formFinish.submit();
546
+ }
547
+ });
548
+
549
+ // Sort mode specific save function
550
+ async function sortModeSaveData(isFinishSession = false) {
551
+ // Run validation callback if it exists
552
+ if (window.beforeSaveData && typeof window.beforeSaveData === 'function') {
553
+ const validationResult = window.beforeSaveData(isFinishSession);
554
+ if (validationResult === false) {
555
+ return false;
556
+ }
557
+ }
558
+
559
+ // Build products array with basic position info and group assignment
560
+ const products = [];
561
+ for (const [code, point] of Object.entries(window.placedPoints)) {
562
+ const groupId = productToGroup[code];
563
+ products.push({
564
+ code: code,
565
+ x: point.x,
566
+ y: point.y,
567
+ idProduct: point.id,
568
+ group: groupId || "" // Empty string if no group assigned
569
+ });
570
+ }
571
+
572
+ // Build groups object with word arrays
573
+ const groups = {};
574
+ const groupIds = Object.keys(productGroups);
575
+
576
+ // Only include groups if they exist
577
+ if (groupIds.length > 0) {
578
+ for (const groupId of groupIds) {
579
+ groups[groupId] = groupWords[groupId] || [];
580
+ }
581
+ }
582
+
583
+ // Build the data structure
584
+ const data = { products: products };
585
+
586
+ // Only add groups if they exist
587
+ if (Object.keys(groups).length > 0) {
588
+ data.groups = groups;
589
+ }
590
+
591
+ const URL = "/cata/testers/api/rating-napping";
592
+ const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
593
+
594
+ try {
595
+ const response = await fetch(URL, {
596
+ method: "POST",
597
+ headers: {
598
+ "Content-Type": "application/json",
599
+ "X-CSRFToken": csrfToken,
600
+ },
601
+ body: JSON.stringify(data),
602
+ });
603
+
604
+ if (!response.ok) {
605
+ spanNotifaction("Error en la respuesta del servidor");
606
+ return false;
607
+ }
608
+
609
+ const result = await response.json();
610
+
611
+ if (result.error) {
612
+ spanNotifaction(result.error);
613
+ return false;
614
+ } else {
615
+ spanNotifaction(result.message, false);
616
+ return true;
617
+ }
618
+ } catch (error) {
619
+ spanNotifaction("Error en proceso de guardar los datos");
620
+ return false;
621
+ }
622
+ }
623
+ }
tecnicas/static/js/test-napping-ultra-flash.js ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Store words: { "CODE": ["word1", "word2"] }
2
+ const productWords = {};
3
+
4
+ // Only initialize ultra flash if the mode is active
5
+ if (window.isUltraFlash) {
6
+ initUltraFlash();
7
+ }
8
+
9
+ function initUltraFlash() {
10
+ const continueBtn = document.getElementById('continue-description');
11
+ const questionSaveBtn = document.getElementById('question-save');
12
+ const dialog = document.getElementById('word-dialog');
13
+ const wordForm = document.getElementById('word-form');
14
+ const wordInput = document.querySelector('.cts-input-list-word');
15
+ const wordList = document.getElementById('word-list');
16
+ const dialogProductCode = document.getElementById('dialog-product-code');
17
+
18
+ let isDescriptionPhase = false;
19
+ let currentProductCode = null;
20
+
21
+ const points = document.querySelectorAll('.data-point');
22
+ let hasExistingWords = false;
23
+
24
+ wordInput.value = '';
25
+
26
+ // Check if there are existing words from backend
27
+ points.forEach(point => {
28
+ const code = point.dataset.code;
29
+ const wordsAttr = point.dataset.words;
30
+
31
+ if (wordsAttr && wordsAttr.trim() !== '') {
32
+ productWords[code] = wordsAttr.split(',').filter(w => w.trim() !== '');
33
+
34
+ if (productWords[code].length >= 1) {
35
+ hasExistingWords = true;
36
+ }
37
+ }
38
+ });
39
+
40
+ setTimeout(() => {
41
+ if (hasExistingWords) {
42
+ startDescriptionPhase();
43
+ // Update all point labels to show existing words
44
+ points.forEach(point => {
45
+ const code = point.dataset.code;
46
+ if (productWords[code] && productWords[code].length > 0) {
47
+ updatePointLabel(code);
48
+ }
49
+ });
50
+ } else {
51
+ // No existing words, show continue button for phase 1
52
+ continueBtn.classList.remove('hidden');
53
+ }
54
+ }, 100);
55
+
56
+ // Check if all products are placed
57
+ continueBtn.addEventListener('click', () => {
58
+ const placedCount = Object.keys(window.placedPoints).length;
59
+ const totalProducts = document.querySelectorAll('.item-product').length;
60
+
61
+ if (placedCount !== totalProducts) {
62
+ spanNotifaction("Por favor, coloca todos los productos antes de continuar.");
63
+ return;
64
+ }
65
+
66
+ startDescriptionPhase();
67
+ });
68
+
69
+ function startDescriptionPhase() {
70
+ isDescriptionPhase = true;
71
+ window.isPlacementActive = false;
72
+ continueBtn.classList.add('hidden');
73
+ questionSaveBtn.classList.remove("hidden");
74
+ spanNotifaction("Fase de descripción: Haz clic en un punto para agregar palabras.", false);
75
+
76
+ const plane = document.getElementById('napping-plane');
77
+ plane.classList.remove('cursor-crosshair');
78
+ plane.classList.add('cursor-default');
79
+ document.querySelectorAll('.item-product').forEach(p => {
80
+ p.classList.remove('ring-4', 'ring-primary');
81
+ });
82
+
83
+ // Auto-save positions when transitioning to description phase
84
+ window.saveData(false);
85
+ }
86
+
87
+ // Handle Point Click for Description
88
+ // We need to attach this to the plane or points.
89
+ // Since points are re-rendered, delegating to plane is better, or hooking into renderPoint.
90
+ // But renderPoint is in the other file.
91
+ // Let's use event delegation on the plane, but we need to catch the click on the point.
92
+
93
+ document.getElementById('napping-plane').addEventListener('click', (e) => {
94
+ if (!isDescriptionPhase) return;
95
+
96
+ const point = e.target.closest('.data-point');
97
+
98
+ if (point) {
99
+ e.stopPropagation();
100
+ openWordDialog(point.dataset.code);
101
+ }
102
+ });
103
+
104
+ function openWordDialog(code) {
105
+ currentProductCode = code;
106
+ dialogProductCode.innerText = code;
107
+ renderWordListInDialog();
108
+ dialog.showModal();
109
+ }
110
+
111
+ function renderWordListInDialog() {
112
+ wordList.innerHTML = '';
113
+ const words = productWords[currentProductCode] || [];
114
+
115
+ words.forEach((word, index) => {
116
+ const badge = document.createElement('div');
117
+ badge.className = 'badge badge-secondary gap-2 p-3';
118
+ badge.innerHTML = `
119
+ ${word}
120
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current cursor-pointer remove-word" data-index="${index}"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
121
+ `;
122
+
123
+ badge.querySelector('.remove-word').addEventListener('click', () => {
124
+ removeWord(index);
125
+ });
126
+
127
+ wordList.appendChild(badge);
128
+ });
129
+
130
+ // Update visualization on the plane
131
+ updatePointLabel(currentProductCode);
132
+ }
133
+
134
+ function addWord(word) {
135
+ if (!productWords[currentProductCode]) {
136
+ productWords[currentProductCode] = [];
137
+ }
138
+
139
+ // No maximum limit on words
140
+
141
+ if (productWords[currentProductCode].includes(word)) {
142
+ spanNotifaction("Palabra duplicada");
143
+ return;
144
+ }
145
+
146
+ productWords[currentProductCode].push(word);
147
+ renderWordListInDialog();
148
+ }
149
+
150
+ function removeWord(index) {
151
+ if (productWords[currentProductCode]) {
152
+ productWords[currentProductCode].splice(index, 1);
153
+ renderWordListInDialog();
154
+ }
155
+ }
156
+
157
+ wordForm.addEventListener('submit', (e) => {
158
+ e.preventDefault();
159
+ const word = wordInput.value.trim();
160
+ if (word) {
161
+ addWord(word);
162
+ wordInput.value = '';
163
+ wordInput.focus();
164
+ }
165
+ });
166
+
167
+ function updatePointLabel(code) {
168
+ const point = document.getElementById(`point-${code}`);
169
+ if (!point) return;
170
+
171
+ const words = productWords[code] || [];
172
+
173
+ // Remove existing tooltip if present
174
+ let tooltip = point.querySelector('.cts-tooltip');
175
+
176
+ if (tooltip) {
177
+ tooltip.remove();
178
+ }
179
+
180
+ // Only create tooltip in description phase and if there are words
181
+ if (isDescriptionPhase && words.length > 0) {
182
+ tooltip = document.createElement('div');
183
+ tooltip.className = 'cts-tooltip absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 text-white text-xs rounded z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none';
184
+
185
+ // Display words in a 3-column grid (no coordinates)
186
+ const wordBadges = words.map(w => `<span class="inline-block px-2 py-1 bg-yellow-600 text-white rounded text-xs">${w}</span>`).join('');
187
+ tooltip.innerHTML = `
188
+ <strong>${code}</strong>
189
+ <div class="mt-2 pt-2 border-t border-gray-600" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; max-width: 300px;">
190
+ ${wordBadges}
191
+ </div>
192
+ `;
193
+
194
+ // Add max-width to tooltip
195
+ tooltip.style.maxWidth = '320px';
196
+ tooltip.style.whiteSpace = 'normal';
197
+ point.appendChild(tooltip);
198
+ }
199
+ }
200
+
201
+ // Set up callbacks to extend the base saveData function
202
+ // Validation callback - runs before saving
203
+ window.beforeSaveData = function (isFinishSession = false) {
204
+ // If finishing session, validate all products placed and have words
205
+ if (isFinishSession) {
206
+ const totalProducts = document.querySelectorAll('.item-product').length;
207
+ const codeProducts = Object.keys(window.placedPoints);
208
+
209
+ // Check all products are placed
210
+ if (codeProducts.length !== totalProducts) {
211
+ spanNotifaction("Por favor, coloca todos los productos antes de finalizar la sesión.");
212
+ return false;
213
+ }
214
+
215
+ // Check each product has at least 1 word
216
+ for (const code of codeProducts) {
217
+ const words = productWords[code] || [];
218
+ if (words.length < 1) {
219
+ spanNotifaction(`El producto ${code} debe tener al menos 1 palabra para finalizar la sesión.`);
220
+ return false;
221
+ }
222
+ }
223
+ }
224
+ // For progress save, no validation needed
225
+ return true;
226
+ };
227
+
228
+ // Data extension callback - adds words to each product's data
229
+ window.getExtraDataForSave = function (code) {
230
+ const words = productWords[code] || [];
231
+ return {
232
+ words: words
233
+ };
234
+ };
235
+ }
tecnicas/static/js/test-napping.js DELETED
@@ -1,209 +0,0 @@
1
- const planeContainer = document.getElementById('napping-plane');
2
- const productsContainer = document.getElementById('items');
3
- const products = document.querySelectorAll('.item-product');
4
-
5
- // Configuration for the physical dimensions of the tablecloth (in cm)
6
- const PHYSICAL_WIDTH = 60;
7
- const PHYSICAL_HEIGHT = 40;
8
-
9
- let selectedProductCode = null;
10
- let selectedProductId = null;
11
-
12
- // Object to store coordinates: { "CODE": { x: 10.5, y: 20.1, id: 123 } }
13
- const placedPoints = {};
14
-
15
- // 1. Handle Product Selection
16
- products.forEach(product => {
17
- product.addEventListener('click', () => {
18
- // Remove selection from others
19
- products.forEach(p => p.classList.remove('ring-4', 'ring-primary'));
20
-
21
- // Select current
22
- product.classList.add('ring-4', 'ring-primary');
23
- selectedProductCode = product.dataset.code;
24
- selectedProductId = product.dataset.idProduct;
25
- });
26
- });
27
-
28
- // 2. Handle Plane Click (Placing Points)
29
- planeContainer.addEventListener('click', (e) => {
30
- if (!selectedProductCode) {
31
- spanNotifaction("Por favor, selecciona un producto primero")
32
- return;
33
- }
34
-
35
- const rect = planeContainer.getBoundingClientRect();
36
-
37
- // Calculate click position relative to the container (in pixels)
38
- const xPixel = e.clientX - rect.left;
39
- const yPixel = e.clientY - rect.top;
40
-
41
- // Calculate scaled coordinates (0 to PHYSICAL_DIMENSIONS)
42
- // X axis: 0 on left, 60 on right
43
- const xCoord = (xPixel / rect.width) * PHYSICAL_WIDTH;
44
-
45
- // Y axis: 0 on bottom, 40 on top (Invert Y because DOM Y is 0 at top)
46
- const yCoord = PHYSICAL_HEIGHT - ((yPixel / rect.height) * PHYSICAL_HEIGHT);
47
-
48
- // Update Data Object
49
- placedPoints[selectedProductCode] = {
50
- x: parseFloat(xCoord.toFixed(2)),
51
- y: parseFloat(yCoord.toFixed(2)),
52
- id: selectedProductId
53
- };
54
-
55
- // Render Point
56
- renderPoint(selectedProductCode, xPixel, yPixel, xCoord, yCoord);
57
- });
58
-
59
- function renderPoint(code, xPx, yPx, xVal, yVal) {
60
- // Remove existing point for this product if it exists
61
- const existingPoint = document.getElementById(`point-${code}`);
62
- if (existingPoint) {
63
- existingPoint.remove();
64
- }
65
-
66
- const point = document.createElement('div');
67
- point.id = `point-${code}`;
68
- point.className = 'absolute w-4 h-4 bg-red-600 rounded-full transform -translate-x-1/2 -translate-y-1/2 cursor-pointer border-2 border-white shadow-md group';
69
- point.style.left = `${xPx}px`;
70
- point.style.top = `${yPx}px`;
71
-
72
- // Tooltip/Label
73
- const label = document.createElement('div');
74
- label.className = 'absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded whitespace-nowrap z-10 hidden group-hover:block';
75
- label.innerHTML = `
76
- <strong>${code}</strong><br>
77
- X: ${xVal.toFixed(1)}<br>
78
- Y: ${yVal.toFixed(1)}
79
- `;
80
-
81
- point.appendChild(label);
82
- planeContainer.appendChild(point);
83
-
84
- const textLabel = document.createElement('span');
85
- textLabel.className = 'absolute top-4 left-1/2 transform -translate-x-1/2 text-xs font-bold text-gray-700 pointer-events-none';
86
- textLabel.innerText = code;
87
- point.appendChild(textLabel);
88
- }
89
-
90
- function checkPoints() {
91
- const points = document.querySelectorAll('.data-point');
92
-
93
- points.forEach(point => {
94
- const code = point.dataset.code;
95
-
96
- const xVal = parseFloat(point.dataset.px);
97
- const yVal = parseFloat(point.dataset.py);
98
-
99
- const rect = planeContainer.getBoundingClientRect();
100
-
101
- const px = (xVal / PHYSICAL_WIDTH) * rect.width;
102
- const py = ((PHYSICAL_HEIGHT - yVal) / PHYSICAL_HEIGHT) * rect.height;
103
-
104
- placedPoints[code] = {
105
- x: parseFloat(xVal.toFixed(2)),
106
- y: parseFloat(yVal.toFixed(2)),
107
- id: point.dataset.idProduct
108
- };
109
-
110
- renderPoint(code, px, py, xVal, yVal);
111
- });
112
- }
113
-
114
- checkPoints();
115
-
116
- /*
117
- ////
118
- //////
119
- //////// Question to finish session
120
- //////
121
- ////
122
- */
123
-
124
- function showOptionsSave() {
125
- document.getElementById("question-save").classList.add("hidden");
126
- document.getElementById("finish-session").classList.remove("hidden");
127
- document.getElementById("cancel-save").classList.remove("hidden");
128
- }
129
-
130
- function showQuestionSave() {
131
- document.getElementById("question-save").classList.remove("hidden");
132
- document.getElementById("finish-session").classList.add("hidden");
133
- document.getElementById("cancel-save").classList.add("hidden");
134
- }
135
-
136
- document
137
- .getElementById("question-save")
138
- .addEventListener("click", showOptionsSave);
139
-
140
- document
141
- .getElementById("cancel-save")
142
- .addEventListener("click", showQuestionSave);
143
-
144
- /*
145
- ////
146
- //////
147
- //////// Save data and finish session
148
- //////
149
- ////
150
- */
151
-
152
- async function saveData() {
153
- const codeProducts = Object.keys(placedPoints);
154
- const data = [];
155
-
156
- if (products.length != codeProducts.length) {
157
- spanNotifaction("Por favor, coloca todos los puntos")
158
- return;
159
- }
160
-
161
- codeProducts.forEach((code) => {
162
- const point = placedPoints[code];
163
-
164
- const objData = {
165
- code: code,
166
- x: point.x,
167
- y: point.y,
168
- idProduct: point.id
169
- };
170
-
171
- data.push(objData);
172
- })
173
-
174
- const URL = "/cata/testers/api/rating-napping/no-mode"
175
- const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
176
-
177
- try {
178
- const response = await fetch(URL, {
179
- method: "POST",
180
- headers: {
181
- "Content-Type": "application/json",
182
- "X-CSRFToken": csrfToken,
183
- },
184
- body: JSON.stringify(data),
185
- })
186
-
187
- if (!response.ok) {
188
- spanNotifaction("Error en la respuesta del servidor")
189
- return false;
190
- }
191
-
192
- const result = await response.json()
193
-
194
- if (result.error) {
195
- spanNotifaction(result.error)
196
- return false
197
- } else {
198
- spanNotifaction(result.message, false)
199
- return true
200
- }
201
- } catch (error) {
202
- spanNotifaction("Error en proceso de guardar los datos")
203
- return false
204
- }
205
- }
206
-
207
- document
208
- .getElementById("save-progress")
209
- .addEventListener("click", saveData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tecnicas/templates/tecnicas/components/dialog-nap-puf.html ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <dialog id="word-dialog" class="modal">
2
+ <div class="modal-box bg-surface-ligt space-y-4">
3
+ <h3 class="font-bold text-lg">
4
+ Describir producto
5
+ <span id="dialog-product-code"></span>
6
+ </h3>
7
+
8
+ <form id="word-form" class="cts-form-pf-word flex justify-center items-center gap-4">
9
+ <label for="{{ form.nombre_palabra.id_for_label }}" class="text-left flex-1">
10
+ {{ form.nombre_palabra }}
11
+ </label>
12
+ <button type="submit" class="cts-btn-general-compress cts-btn-primary btn-push py-1 px-4">
13
+ Agregar
14
+ </button>
15
+ </form>
16
+
17
+ <div id="word-list" class="flex flex-wrap gap-2 mb-4 min-h-[50px]"></div>
18
+
19
+ <div class="modal-action">
20
+ <form method="dialog">
21
+ <button class="btn">Cerrar</button>
22
+ </form>
23
+ </div>
24
+ </div>
25
+ <form method="dialog" class="modal-backdrop">
26
+ <button>close</button>
27
+ </form>
28
+ </dialog>
tecnicas/templates/tecnicas/components/dialog-nap-sort.html ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <dialog id="group-word-dialog" class="modal">
2
+ <div class="modal-box max-w-2xl bg-surface-ligt">
3
+ <h3 class="font-bold text-lg">Describir Grupo: <span id="dialog-group-id"></span></h3>
4
+ <p class="py-2 text-sm text-gray-600">Agrega palabras para describir este grupo de productos</p>
5
+
6
+ <form id="group-word-form" class="space-y-4 flex justify-center items-center gap-4">
7
+ <label for="{{ form.nombre_palabra.id_for_label }}" class="text-left flex-1">
8
+ {{ form.nombre_palabra }}
9
+ </label>
10
+ <button type="submit" class="cts-btn-general-compress cts-btn-primary btn-push py-1 px-4">
11
+ Agregar
12
+ </button>
13
+ </form>
14
+
15
+ <div class="mt-4">
16
+ <h4 class="font-semibold mb-2">Palabras agregadas:</h4>
17
+ <div id="group-word-list" class="flex flex-wrap gap-2"></div>
18
+ </div>
19
+
20
+ <div class="modal-action">
21
+ <form method="dialog">
22
+ <button class="cts-btn-general cts-btn-secondary btn-push">Cerrar</button>
23
+ </form>
24
+ </div>
25
+ </div>
26
+ <form method="dialog" class="modal-backdrop">
27
+ <button>close</button>
28
+ </form>
29
+ </dialog>
tecnicas/templates/tecnicas/components/table-napping-puf.html ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% load custom_filters %}
2
+ <article class="space-y-4 text-black">
3
+ <h2 class="font-bold text-xl text-center">
4
+ Datos de Napping con Perfil Ultra Flash
5
+ </h2>
6
+
7
+ <div class="overflow-x-auto rounded-lg border border-surface-general">
8
+ <table id="generic-donwload-table" class="min-w-max w-full text-sm text-center border-collapse">
9
+ <thead class="bg-surface-sweet font-semibold">
10
+ <tr>
11
+ <th class="py-2 px-3 border border-surface-general">Producto</th>
12
+ {% for tester in testers %}
13
+ <th class="py-2 px-3 border border-surface-general uppercase">
14
+ X{{ forloop.counter }}
15
+ </th>
16
+ <th class="py-2 px-3 border border-surface-general uppercase">
17
+ Y{{ forloop.counter }}
18
+ </th>
19
+ {% endfor %}
20
+ {% for word in all_words %}
21
+ <th class="py-2 px-3 border border-surface-general capitalize">
22
+ {{ word }}
23
+ </th>
24
+ {% endfor %}
25
+ </tr>
26
+ </thead>
27
+ <tbody class="bg-surface-ligt divide-y divide-gray-200">
28
+ {% for product, coordinates_tester in coordinates_no_mode.items %}
29
+ <tr>
30
+ <td class="py-2 px-3 border border-surface-general">{{ product }}</td>
31
+ {% for tester in testers %}
32
+ {% with points=coordinates_tester|get_item:tester.user.username %}
33
+ <td class="py-2 px-3 border border-surface-general">
34
+ {{ points.px }}
35
+ </td>
36
+ <td class="py-2 px-3 border border-surface-general">
37
+ {{ points.py }}
38
+ </td>
39
+ {% endwith %}
40
+ {% endfor %}
41
+ {% for word in all_words %}
42
+ {% with word_freq=word_frequencies|get_item:product|get_item:word %}
43
+ <td class="py-2 px-3 border border-surface-general">
44
+ {% if word_freq %}{{ word_freq }}{% else %}0{% endif %}
45
+ </td>
46
+ {% endwith %}
47
+ {% endfor %}
48
+ </tr>
49
+ {% endfor %}
50
+ </tbody>
51
+ </table>
52
+ </div>
53
+ </article>
tecnicas/templates/tecnicas/components/table-napping-sorting.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% load custom_filters %}
2
+ <article class="space-y-4 text-black">
3
+ <h2 class="font-bold text-xl text-center">
4
+ Datos de Napping con Sorting
5
+ </h2>
6
+
7
+ <div class="overflow-x-auto rounded-lg border border-surface-general">
8
+ <table id="generic-donwload-table" class="min-w-max w-full text-sm text-center border-collapse">
9
+ <thead class="bg-surface-sweet font-semibold">
10
+ <tr>
11
+ <th class="py-2 px-3 border border-surface-general">Producto</th>
12
+ {% for tester in testers %}
13
+ <th class="py-2 px-3 border border-surface-general uppercase">
14
+ X{{ forloop.counter }}
15
+ </th>
16
+ <th class="py-2 px-3 border border-surface-general uppercase">
17
+ Y{{ forloop.counter }}
18
+ </th>
19
+ <th class="py-2 px-3 border border-surface-general uppercase">
20
+ C{{ forloop.counter }}
21
+ </th>
22
+ {% endfor %}
23
+ </tr>
24
+ </thead>
25
+ <tbody class="bg-surface-ligt divide-y divide-gray-200">
26
+ {% for product, data_per_tester in sorting_data.items %}
27
+ <tr>
28
+ <td class="py-2 px-3 border border-surface-general">{{ product }}</td>
29
+ {% for tester in testers %}
30
+ {% with data=data_per_tester|get_item:tester.user.username %}
31
+ <td class="py-2 px-3 border border-surface-general">
32
+ {{ data.px }}
33
+ </td>
34
+ <td class="py-2 px-3 border border-surface-general">
35
+ {{ data.py }}
36
+ </td>
37
+ <td class="py-2 px-3 border border-surface-general">
38
+ {{ data.words }}
39
+ </td>
40
+ {% endwith %}
41
+ {% endfor %}
42
+ </tr>
43
+ {% endfor %}
44
+ </tbody>
45
+ </table>
46
+ </div>
47
+ </article>
tecnicas/templates/tecnicas/create_sesion/panel-basic-napping.html CHANGED
@@ -28,14 +28,14 @@
28
  </section>
29
  <section class="flex flex-row flex-wrap justify-center gap-4">
30
  <label for="{{ form_sesion.numero_productos.id_for_label }}"
31
- class="text-lg flex flex-col items-center px-2 font-medium tracking-wide">
32
  <p class="tracking-normal text-base font-bold">
33
  Número de Productos:
34
  </p>
35
  {{ form_sesion.numero_productos }}
36
  </label>
37
  <label for="{{ form_sesion.numero_catadores.id_for_label }}"
38
- class="text-lg flex flex-col items-center px-2 font-medium tracking-wide">
39
  <p class="tracking-normal text-base font-bold">
40
  Número de Catadores:
41
  </p>
@@ -51,6 +51,21 @@
51
  </section>
52
  </article>
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  <article class="w-full flex max-sm:flex-col flex-wrap items-center justify-center gap-4">
55
  <button type="submit" class="cts-btn-general cts-btn-primary btn-push flex-1/4 w-full">
56
  Continuar
 
28
  </section>
29
  <section class="flex flex-row flex-wrap justify-center gap-4">
30
  <label for="{{ form_sesion.numero_productos.id_for_label }}"
31
+ class="text-lg flex flex-col items-center px-2 font-medium tracking-wide flex-1">
32
  <p class="tracking-normal text-base font-bold">
33
  Número de Productos:
34
  </p>
35
  {{ form_sesion.numero_productos }}
36
  </label>
37
  <label for="{{ form_sesion.numero_catadores.id_for_label }}"
38
+ class="text-lg flex flex-col items-center px-2 font-medium tracking-wide flex-1">
39
  <p class="tracking-normal text-base font-bold">
40
  Número de Catadores:
41
  </p>
 
51
  </section>
52
  </article>
53
 
54
+ <article class="space-y-4">
55
+ <h2 class="text-2xl font-bold">Elije Modalidad</h2>
56
+ <section class="flex flex-row gap-4 justify-around flex-wrap">
57
+ {% for choice in form_sesion.modalidad %}
58
+ <label for="{{choice.id_for_label}}"
59
+ class="cts-btn-general-compress cts-btn-secondary btn-push px-4 py-2 uppercase flex-1 flex items-center justify-around gap-2">
60
+ {{ choice.tag }}
61
+ <p class="italic text-center flex-1">
62
+ {{ choice.choice_label }}
63
+ </p>
64
+ </label>
65
+ {% endfor %}
66
+ </section>
67
+ </article>
68
+
69
  <article class="w-full flex max-sm:flex-col flex-wrap items-center justify-center gap-4">
70
  <button type="submit" class="cts-btn-general cts-btn-primary btn-push flex-1/4 w-full">
71
  Continuar
tecnicas/templates/tecnicas/forms_tester/test_napping.html CHANGED
@@ -18,10 +18,11 @@
18
  </header>
19
 
20
  <article class="hidden">
21
- <form action="{% url 'cata_system:catador_init_session' code_sesion=session.codigo_sesion %}" method="post"
 
22
  class="form-actions">
23
  {% csrf_token %}
24
- <input type="hidden" name="action" class="action-input">
25
  </form>
26
  </article>
27
 
@@ -41,8 +42,8 @@
41
  </section>
42
 
43
  <section class="flex items-center justify-center flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
44
- <p class="text-xl font-bold text-center capitalize">
45
- Modalidad: {% if mode != "sin modalidad" %}{{ mode }} {% else %} Nappging {% endif %}
46
  </p>
47
  </section>
48
  </article>
@@ -123,12 +124,17 @@
123
  <button id="save-progress" class="cts-btn-general cts-btn-primary btn-push">
124
  Guardar progreso
125
  </button>
 
 
 
 
 
126
  <div class="flex gap-2">
127
  <button id="question-save" class="cts-btn-general cts-btn-secondary btn-push flex-1">
128
  ¿Finalizar sesión?
129
  </button>
130
  <button id="finish-session" class="cts-btn-general cts-btn-tertiary btn-push hidden flex-1"
131
- onclick="finishSession('form-actions')">
132
  Finalizar
133
  </button>
134
  <button id="cancel-save" class="cts-btn-general cts-btn-error btn-push hidden flex-1">
@@ -138,7 +144,6 @@
138
  <span id="loading-data-save" class="loading loading-spinner loading-xl text-accent hidden"></span>
139
  </section>
140
  </article>
141
-
142
  {% include "../components/toast-container.html" %}
143
  </article>
144
  </article>
@@ -146,5 +151,5 @@
146
 
147
  {% block extra_js %}
148
  <script src="{% static 'js/actions-form.js' %}"></script>
149
- <script src="{% static 'js/test-napping.js' %}"></script>
150
  {% endblock %}
 
18
  </header>
19
 
20
  <article class="hidden">
21
+ <form id="form-finish-session"
22
+ action="{% url 'cata_system:catador_init_session' code_sesion=session.codigo_sesion %}" method="post"
23
  class="form-actions">
24
  {% csrf_token %}
25
+ <input type="hidden" name="action" value="finish_session" class="action-input">
26
  </form>
27
  </article>
28
 
 
42
  </section>
43
 
44
  <section class="flex items-center justify-center flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
45
+ <p class="text-xl font-bold text-center capitalize" data-mode="{{ mode }}">
46
+ Modalidad: {% if mode != "sin modalidad" %}{{ mode }} {% else %} Napping {% endif %}
47
  </p>
48
  </section>
49
  </article>
 
124
  <button id="save-progress" class="cts-btn-general cts-btn-primary btn-push">
125
  Guardar progreso
126
  </button>
127
+ {% if mode == "perfil ultra flash" %}
128
+ <button id="continue-description" class="cts-btn-general cts-btn-primary btn-push hidden">
129
+ Continuar a descripción
130
+ </button>
131
+ {% endif %}
132
  <div class="flex gap-2">
133
  <button id="question-save" class="cts-btn-general cts-btn-secondary btn-push flex-1">
134
  ¿Finalizar sesión?
135
  </button>
136
  <button id="finish-session" class="cts-btn-general cts-btn-tertiary btn-push hidden flex-1"
137
+ onclick="window.finishSession()">
138
  Finalizar
139
  </button>
140
  <button id="cancel-save" class="cts-btn-general cts-btn-error btn-push hidden flex-1">
 
144
  <span id="loading-data-save" class="loading loading-spinner loading-xl text-accent hidden"></span>
145
  </section>
146
  </article>
 
147
  {% include "../components/toast-container.html" %}
148
  </article>
149
  </article>
 
151
 
152
  {% block extra_js %}
153
  <script src="{% static 'js/actions-form.js' %}"></script>
154
+ <script src="{% static 'js/test-napping-plane.js' %}"></script>
155
  {% endblock %}
tecnicas/templates/tecnicas/forms_tester/test_napping_puf.html ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'tecnicas/layouts/base.html' %}
2
+ {% load static %}
3
+ {% load custom_filters %}
4
+
5
+ {% block title %}Napping{% endblock %}
6
+
7
+ {% block content %}
8
+ <article class="cts-container-main">
9
+ <article class="cts-wrap-content text-black max-w-4xl">
10
+ <header class="text-center flex-row w-full items-stretch flex justify-around flex-wrap gap-2">
11
+ <h1 class="rounded font-bold text-2xl bg-surface-ligt p-4 flex-1">
12
+ Sesión usando <br>técnica
13
+ <span class="uppercase">{{ session.tecnica.tipo_tecnica }}</span>
14
+ </h1>
15
+ <button class="cts-btn-general cts-btn-error btn-push" onclick="exit_sesion('form-actions')">
16
+ Salir de la sesión
17
+ </button>
18
+ </header>
19
+
20
+ <article class="hidden">
21
+ <form id="form-finish-session"
22
+ action="{% url 'cata_system:catador_init_session' code_sesion=session.codigo_sesion %}" method="post"
23
+ class="form-actions">
24
+ {% csrf_token %}
25
+ <input type="hidden" name="action" value="finish_session" class="action-input">
26
+ </form>
27
+ </article>
28
+
29
+ <section class="hidden">
30
+ <input type="hidden" value="{{ session.tecnica.id }}" name="id-tecnica" class="ct-input-id-tech">
31
+ </section>
32
+
33
+ {% if error %}
34
+ {% include "../components/error-message.html" with message=error %}
35
+ {% endif %}
36
+
37
+ <article class="rounded flex flex-col gap-4">
38
+ <section class="flex items-center justify-center flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
39
+ <p class="text-xl font-bold text-center capitalize" data-mode="{{ mode }}">
40
+ Modalidad: {% if mode != "sin modalidad" %}{{ mode }} {% else %} Nappging {% endif %}
41
+ </p>
42
+ </section>
43
+ </article>
44
+
45
+ <article class="container-rating-word p-2 py-6 space-y-6 bg-surface-ligt rounded min-w-3xl">
46
+ <section class="flex items-center justify-around flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
47
+ <h2 class="text-xl font-bold">Productos en la sesión</h2>
48
+ <div class="flex flex-col items-center justify-center flex-wrap">
49
+ <p class="text-lg font-bold text-center">Instrucciones</p>
50
+ <p class="text-lg font-normal text-center">
51
+ {{ session.tecnica.instrucciones }}
52
+ </p>
53
+ </div>
54
+ </section>
55
+ <div id="items" class="original-products flex gap-4 flex-wrap justify-center"
56
+ data-original-products="original">
57
+ {% for product in products %}
58
+ <div class="item-product bg-btn-secondary text-black font-bold px-4 py-2 rounded cursor-grab transition-all"
59
+ data-id-product="{{ product.id }}" data-code="{{ product.codigoProducto }}">
60
+ {{ product.codigoProducto }}
61
+ </div>
62
+ {% endfor %}
63
+ </div>
64
+
65
+ <!-- Cartesian Plane Container -->
66
+ <div class="flex justify-center w-full">
67
+ <div class="relative w-full max-w-[800px] aspect-[3/2] bg-white border-2 border-gray-400 shadow-inner cursor-crosshair"
68
+ id="napping-plane"
69
+ style="background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px;">
70
+
71
+ <span class="absolute bottom-1 right-2 text-xs text-gray-500 font-bold">60cm (X)</span>
72
+ <span class="absolute top-2 left-1 text-xs text-gray-500 font-bold">40cm (Y)</span>
73
+ <span class="absolute bottom-1 left-1 text-xs text-gray-500 font-bold">0</span>
74
+
75
+ {% for point in data_points %}
76
+ <div id="point-{{ point.code }}"
77
+ class="data-point absolute w-4 h-4 bg-red-600 rounded-full transform -translate-x-1/2 -translate-y-1/2 cursor-pointer border-2 border-white shadow-md group"
78
+ data-px="{{ point.px }}" data-py="{{ point.py }}" data-code="{{ point.code }}"
79
+ data-id-product="{{ point.id_product }}" {% if words_by_product|get_item:point.code %}
80
+ data-words="{{ words_by_product|get_item:point.code|join:',' }}" {% endif %}>
81
+ <div
82
+ class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded whitespace-nowrap z-10 hidden group-hover:block">
83
+ <strong>{{ point.code }}</strong>
84
+ </div>
85
+ <span
86
+ class="absolute top-4 left-1/2 transform -translate-x-1/2 text-xs font-bold text-gray-700 pointer-events-none">
87
+ {{ point.code }}
88
+ </span>
89
+ </div>
90
+ {% endfor %}
91
+ </div>
92
+ </div>
93
+
94
+ <section role="alert" class="alert alert-info">
95
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
96
+ class="h-6 w-6 shrink-0 stroke-current">
97
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
98
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 0 0118 0z"></path>
99
+ </svg>
100
+ <span class="text-lg">
101
+ Para guardar los datos sin finalizar su sesión use el botón
102
+ <b>"Guardar progreso"</b>
103
+ </span>
104
+ </section>
105
+
106
+ <section role="alert" class="alert alert-warning">
107
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
108
+ viewBox="0 0 24 24">
109
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
110
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
111
+ </svg>
112
+ <div class="flex-col">
113
+ <span class="text-lg block">
114
+ Si ya has agregado <b>atributos</b> a un punto y <b>guardas el progreso</b> ya no será posible
115
+ volver a mover los puntos
116
+ </span>
117
+ </div>
118
+ </section>
119
+
120
+ <section role="alert" class="alert alert-warning">
121
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
122
+ viewBox="0 0 24 24">
123
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
124
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
125
+ </svg>
126
+ <div class="flex-col">
127
+ <span class="text-lg block">
128
+ Si usas el botón <b>“Salir de la sesión”</b> asegúrese de guardar el progreso de lo contrario
129
+ perderás todo lo que no hayas guardado antes
130
+ </span>
131
+ <span class="text-lg block">
132
+ Con el botón <b>“Finalizar”</b>, se guardan las posiciones, sales de la sesión y no
133
+ podrás ingresar otra vez
134
+ </span>
135
+ </div>
136
+ </section>
137
+
138
+ <section class="flex justify-end gap-4 max-sm:flex-col">
139
+ <button id="save-progress" class="cts-btn-general cts-btn-primary btn-push">
140
+ Guardar progreso
141
+ </button>
142
+ <button id="continue-description" class="cts-btn-general cts-btn-primary btn-push hidden">
143
+ Continuar a descripción
144
+ </button>
145
+ <div class="flex gap-2">
146
+ <button id="question-save" class="cts-btn-general cts-btn-secondary btn-push flex-1">
147
+ ¿Finalizar sesión?
148
+ </button>
149
+ <button id="finish-session" class="cts-btn-general cts-btn-tertiary btn-push hidden flex-1"
150
+ onclick="window.finishSession()">
151
+ Finalizar
152
+ </button>
153
+ <button id="cancel-save" class="cts-btn-general cts-btn-error btn-push hidden flex-1">
154
+ Cancelar
155
+ </button>
156
+ </div>
157
+ <span id="loading-data-save" class="loading loading-spinner loading-xl text-accent hidden"></span>
158
+ </section>
159
+ </article>
160
+
161
+ {% include "../components/dialog-nap-puf.html" %}
162
+
163
+ {% include "../components/toast-container.html" %}
164
+ </article>
165
+ </article>
166
+ {% endblock %}
167
+
168
+ {% block extra_js %}
169
+ <script src="{% static 'js/actions-form.js' %}"></script>
170
+ <script src="{% static 'js/test-napping-plane.js' %}"></script>
171
+ <script src="{% static 'js/test-napping-ultra-flash.js' %}"></script>
172
+ {% endblock %}
tecnicas/templates/tecnicas/forms_tester/test_napping_sort.html ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends 'tecnicas/layouts/base.html' %}
2
+
3
+ {% load static %}
4
+
5
+ {% block title %}Napping{% endblock %}
6
+
7
+ {% block content %}
8
+ <article class="cts-container-main">
9
+ <article class="cts-wrap-content text-black max-w-4xl">
10
+ <header class="text-center flex-row w-full items-stretch flex justify-around flex-wrap gap-2">
11
+ <h1 class="rounded font-bold text-2xl bg-surface-ligt p-4 flex-1">
12
+ Sesión usando <br>técnica
13
+ <span class="uppercase">{{ session.tecnica.tipo_tecnica }}</span>
14
+ </h1>
15
+ <button class="cts-btn-general cts-btn-error btn-push" onclick="exit_sesion('form-actions')">
16
+ Salir de la sesión
17
+ </button>
18
+ </header>
19
+
20
+ <article class="hidden">
21
+ <form id="form-finish-session"
22
+ action="{% url 'cata_system:catador_init_session' code_sesion=session.codigo_sesion %}" method="post"
23
+ class="form-actions">
24
+ {% csrf_token %}
25
+ <input type="hidden" name="action" value="finish_session" class="action-input">
26
+ </form>
27
+ </article>
28
+
29
+ <section class="hidden">
30
+ <input type="hidden" value="{{ session.tecnica.id }}" name="id-tecnica" class="ct-input-id-tech">
31
+ </section>
32
+
33
+ {% if error %}
34
+ {% include "../components/error-message.html" with message=error %}
35
+ {% endif %}
36
+
37
+ <article class="rounded flex flex-col gap-4">
38
+ <section class="flex items-center justify-center flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
39
+ <p class="text-xl font-bold text-center capitalize" data-mode="{{ mode }}">
40
+ Modalidad: {% if mode != "sin modalidad" %}{{ mode }} {% else %} Napping {% endif %}
41
+ </p>
42
+ </section>
43
+ </article>
44
+
45
+ <article class="container-rating-word p-2 py-6 space-y-6 bg-surface-ligt rounded min-w-3xl">
46
+ <section class="flex items-center justify-around flex-wrap gap-4 bg-surface-ligt p-2 rounded-lg">
47
+ <h2 class="text-xl font-bold">Productos en la sesión</h2>
48
+ <div class="flex flex-col items-center justify-center flex-wrap">
49
+ <p class="text-lg font-bold text-center">Instrucciones</p>
50
+ <p class="text-lg font-normal text-center">
51
+ {{ session.tecnica.instrucciones }}
52
+ </p>
53
+ </div>
54
+ </section>
55
+
56
+ <section class="flex max-sm:flex-col gap-2 justify-around">
57
+ <article id="items" class="original-products flex gap-4 flex-wrap justify-center"
58
+ data-original-products="original">
59
+ {% for product in products %}
60
+ <div class="item-product bg-btn-secondary text-black font-bold px-4 py-2 rounded cursor-grab transition-all"
61
+ data-id-product="{{ product.id }}" data-code="{{ product.codigoProducto }}">
62
+ {{ product.codigoProducto }}
63
+ </div>
64
+ {% endfor %}
65
+ </article>
66
+
67
+ <article class="flex gap-2">
68
+ <button id="continue-grouping"
69
+ class="cts-btn-general-compress py-1 px-4 cts-btn-primary btn-push hidden">
70
+ Continuar a Agrupación
71
+ </button>
72
+ <button id="continue-description"
73
+ class="cts-btn-general-compress py-1 px-4 cts-btn-primary btn-push hidden">
74
+ Continuar a Descripción
75
+ </button>
76
+ </article>
77
+ </section>
78
+
79
+ <!-- Phase 2: Group Creation Controls (Hidden initially, shown in Sort mode) -->
80
+ <section id="group-controls" class="hidden space-y-2">
81
+ <div class="flex gap-4 justify-center items-center flex-wrap">
82
+ <button id="create-group-btn" class="cts-btn-general-compress py-1 px-4 cts-btn-primary btn-push">
83
+ Crear Grupo
84
+ </button>
85
+ <button id="dissolve-group-btn" class="cts-btn-general-compress py-1 px-4 cts-btn-error btn-push">
86
+ Disolver Grupo
87
+ </button>
88
+ </div>
89
+
90
+ {% if groups %}
91
+ <div class="data-group-products hidden">
92
+ {% for group in groups %}
93
+ <ul class="item-group" {% if group.words %} data-words="{{ group.words|join:',' }}" {% endif %}>
94
+ {% for product in group.products %}
95
+ <li class="item-group-product" data-id-product="{{ product.id }}"
96
+ data-code="{{ product.codigoProducto }}"></li>
97
+ {% endfor %}
98
+ </ul>
99
+ {% endfor %}
100
+ </div>
101
+ {% endif %}
102
+
103
+ <!-- Display existing groups -->
104
+ <div id="groups-display" class="flex gap-3 flex-wrap justify-center"></div>
105
+ </section>
106
+
107
+ <!-- Cartesian Plane Container -->
108
+ <div class="flex justify-center w-full">
109
+ <div class="relative w-full max-w-[800px] aspect-[3/2] bg-white border-2 border-gray-400 shadow-inner cursor-crosshair"
110
+ id="napping-plane"
111
+ style="background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); background-size: 20px 20px;">
112
+
113
+ <span class="absolute bottom-1 right-2 text-xs text-gray-500 font-bold">60cm (X)</span>
114
+ <span class="absolute top-2 left-1 text-xs text-gray-500 font-bold">40cm (Y)</span>
115
+ <span class="absolute bottom-1 left-1 text-xs text-gray-500 font-bold">0</span>
116
+
117
+ {% for point in data_points %}
118
+ <div id="point-{{ point.code }}"
119
+ class="data-point absolute w-4 h-4 bg-red-600 rounded-full transform -translate-x-1/2 -translate-y-1/2 cursor-pointer border-2 border-white shadow-md group"
120
+ data-px="{{ point.px }}" data-py="{{ point.py }}" data-code="{{ point.code }}"
121
+ data-id-product="{{ point.id_product }}">
122
+ <div
123
+ class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-gray-800 text-white text-xs rounded whitespace-nowrap z-10 hidden group-hover:block">
124
+ <strong>{{ point.code }}</strong><br>
125
+ X: {{ point.px }}<br>
126
+ Y: {{ point.py }}
127
+ </div>
128
+ <span
129
+ class="absolute top-4 left-1/2 transform -translate-x-1/2 text-xs font-bold text-gray-700 pointer-events-none">
130
+ {{ point.code }}
131
+ </span>
132
+ </div>
133
+ {% endfor %}
134
+ </div>
135
+ </div>
136
+
137
+ <section role="alert" class="alert alert-info">
138
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
139
+ class="h-6 w-6 shrink-0 stroke-current">
140
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
141
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 0 0118 0z"></path>
142
+ </svg>
143
+ <span class="text-lg">
144
+ Para guardar los datos sin finalizar su sesión use el botón
145
+ <b>"Guardar progreso"</b>
146
+ </span>
147
+ </section>
148
+
149
+ <section role="alert" class="alert alert-warning">
150
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
151
+ viewBox="0 0 24 24">
152
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
153
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
154
+ </svg>
155
+ <div class="flex-col">
156
+ <span class="text-lg block">
157
+ Si usas el botón <b>"Salir de la sesión"</b> asegúrese de guardar el progreso de lo contrario
158
+ perderás todo lo que no hayas guardado antes
159
+ </span>
160
+ <span class="text-lg block">
161
+ Con el botón <b>"Finalizar"</b>, se guardan las posiciones, sales de la sesión y no
162
+ podrás ingresar otra vez
163
+ </span>
164
+ </div>
165
+ </section>
166
+
167
+ <section class="flex justify-end gap-4 max-sm:flex-col">
168
+ <button id="save-progress" class="cts-btn-general cts-btn-primary btn-push">
169
+ Guardar progreso
170
+ </button>
171
+ <div class="flex gap-2">
172
+ <button id="question-save" class="cts-btn-general cts-btn-secondary btn-push flex-1">
173
+ ¿Finalizar sesión?
174
+ </button>
175
+ <button id="finish-session" class="cts-btn-general cts-btn-tertiary btn-push hidden flex-1"
176
+ onclick="window.finishSession()">
177
+ Finalizar
178
+ </button>
179
+ <button id="cancel-save" class="cts-btn-general cts-btn-error btn-push hidden flex-1">
180
+ Cancelar
181
+ </button>
182
+ </div>
183
+ <span id="loading-data-save" class="loading loading-spinner loading-xl text-accent hidden"></span>
184
+ </section>
185
+ </article>
186
+
187
+ {% include "../components/dialog-nap-sort.html" with form=form %}
188
+
189
+ {% include "../components/toast-container.html" %}
190
+ </article>
191
+ </article>
192
+ {% endblock %}
193
+
194
+ {% block extra_js %}
195
+ <script src="{% static 'js/actions-form.js' %}"></script>
196
+ <script src="{% static 'js/test-napping-plane.js' %}"></script>
197
+ <script src="{% static 'js/test-napping-sort.js' %}"></script>
198
+ {% endblock %}
tecnicas/templates/tecnicas/manage_sesions/details-session-napping.html CHANGED
@@ -54,7 +54,7 @@
54
  </section>
55
 
56
  <section
57
- class="bg-surface-card flex flex-wrap items-center justify-center max-sm:justify-normal gap-x-2 p-4 rounded-2xl">
58
  <p class="font-bold">
59
  Fecha creación:
60
  </p>
@@ -83,6 +83,13 @@
83
  </p>
84
  </section>
85
 
 
 
 
 
 
 
 
86
  <section
87
  class="bg-surface-card flex flex-wrap items-center justify-center max-sm:justify-normal gap-x-2 p-4 rounded-2xl">
88
  <p class="font-sans">
@@ -115,16 +122,23 @@
115
  </p>
116
  <article class="grid grid-cols-2 gap-4 max-sm:gap-2">
117
  {% if not session.activo %}
118
- {% for mode in modes %}
 
 
 
 
 
 
 
119
  <button
120
  class="cts-btn-general capitalize cts-btn-primary btn-push flex flex-col justify-center items-center gap-2"
121
- data-mode="{{ mode.nombre }}" onclick="startSession('{{ mode.nombre }}')">
122
- Napping {% if mode.nombre != "sin modalidad" %} {{ mode.nombre }} {% endif %}
123
  <figure class="w-10">
124
  <img src="{% static 'img/giro.svg' %}" alt="flechas girando" class="invert">
125
  </figure>
126
  </button>
127
- {% endfor %}
128
  {% else %}
129
  <a href="{% url 'cata_system:monitor_sesion' session_code=session.codigo_sesion %}" class="w-full">
130
  <button
@@ -178,7 +192,13 @@
178
  </p>
179
 
180
  {% if there_data %}
 
 
 
 
 
181
  {% include "../components/table-napping-no-mode.html" with testers=testers coordinates_no_mode=coordinates_no_mode %}
 
182
  {% else %}
183
  {% include "../components/error-message.html" with message='Sin datos por mostrar aún' %}
184
  {% endif %}
 
54
  </section>
55
 
56
  <section
57
+ class="bg-surface-card col-span-2 flex flex-wrap items-center justify-center max-sm:justify-normal gap-x-2 p-4 rounded-2xl">
58
  <p class="font-bold">
59
  Fecha creación:
60
  </p>
 
83
  </p>
84
  </section>
85
 
86
+ <section
87
+ class="bg-surface-ligt flex flex-wrap items-center justify-center max-sm:justify-normal gap-x-2 p-4 rounded-2xl">
88
+ <p class="font-sans">
89
+ <b>Modalidad:</b> <span class="capitalize">{{ mod_tech }}</span>
90
+ </p>
91
+ </section>
92
+
93
  <section
94
  class="bg-surface-card flex flex-wrap items-center justify-center max-sm:justify-normal gap-x-2 p-4 rounded-2xl">
95
  <p class="font-sans">
 
122
  </p>
123
  <article class="grid grid-cols-2 gap-4 max-sm:gap-2">
124
  {% if not session.activo %}
125
+ {% if finished %}
126
+ <div
127
+ class="text-2xl font-semibold flex-1 cts-btn-secondary p-4 flex justify-center items-center rounded-lg select-none text-center">
128
+ <p class=" text-black">
129
+ Sesión finalizada
130
+ </p>
131
+ </div>
132
+ {% else %}
133
  <button
134
  class="cts-btn-general capitalize cts-btn-primary btn-push flex flex-col justify-center items-center gap-2"
135
+ data-mode="{{ mode }}" onclick="startSession('{{ mode }}')">
136
+ Iniciar {% if mode != "sin modalidad" %}Napping con {{ mode }}{% else %}Napping{% endif %}
137
  <figure class="w-10">
138
  <img src="{% static 'img/giro.svg' %}" alt="flechas girando" class="invert">
139
  </figure>
140
  </button>
141
+ {% endif %}
142
  {% else %}
143
  <a href="{% url 'cata_system:monitor_sesion' session_code=session.codigo_sesion %}" class="w-full">
144
  <button
 
192
  </p>
193
 
194
  {% if there_data %}
195
+ {% if mode == "perfil ultra flash" %}
196
+ {% include "../components/table-napping-puf.html" with testers=testers coordinates_no_mode=coordinates_no_mode word_frequencies=word_frequencies all_words=all_words %}
197
+ {% elif mode == "sorting" %}
198
+ {% include "../components/table-napping-sorting.html" with testers=testers sorting_data=sorting_data %}
199
+ {% else %}
200
  {% include "../components/table-napping-no-mode.html" with testers=testers coordinates_no_mode=coordinates_no_mode %}
201
+ {% endif %}
202
  {% else %}
203
  {% include "../components/error-message.html" with message='Sin datos por mostrar aún' %}
204
  {% endif %}
tecnicas/templates/tecnicas/manage_sesions/details-session-pf.html CHANGED
@@ -120,6 +120,14 @@
120
  </p>
121
  <article class="flex flex-wrap gap-10 max-sm:gap-2">
122
  {% if not sesion.activo %}
 
 
 
 
 
 
 
 
123
  <button
124
  class="ct-btn-start-repition flex-1 uppercase text-lg max-sm:text-base tracking-wider p-4 border-b-2 active:border-b-0 active:border-t-2 active:border-green-500 border-green-800 transition-all rounded-xl bg-green-600 text-white font-bold disabled:bg-amber-600 flex flex-col justify-center items-center gap-2"
125
  onclick="startRepetition()">
@@ -128,6 +136,7 @@
128
  <img src="{% static 'img/giro.svg' %}" alt="flechas girando" class="invert">
129
  </figure>
130
  </button>
 
131
  {% else %}
132
  <a href="{% url 'cata_system:monitor_sesion' session_code=sesion.codigo_sesion %}" class="flex-1 w-fit">
133
  <button
@@ -232,31 +241,31 @@
232
  </article>
233
 
234
  {% if repeticion == 1 %}
235
- {% if data_ratings %}
236
- {% include "../components/table_pf_all.html" with second_phase=second_phase data_ratings=data_ratings %}
237
- {% else %}
238
- {% include "../components/error-message.html" with message='Sin datos por mostrar aún' %}
239
- {% endif %}
240
  {% else %}
241
- {% if data_ratings %}
242
- <article class="overflow-x-auto py-2 space-y-4">
243
- {% for data_tester in data_ratings %}
244
- {% include "../components/table_pf.html" with data=data_tester session_name=sesion.nombre_sesion session_code=sesion.codigo_sesion %}
245
- {% endfor %}
246
- </article>
247
- <div class="flex justify-end mt-3 gap-4 flex-wrap">
248
- <button id="download-csv-btn" class="cts-btn-general cts-btn-primary btn-push">
249
- Descargar datos CSV en zip
250
- </button>
251
- <button id="download-xls-btn" class="cts-btn-general cts-btn-primary btn-push">
252
- Descargar datos en XLSX
253
- </button>
254
- </div>
255
- <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
256
- <script src="{% static 'js/download-table-xlsx.js' %}"></script>
257
- {% else %}
258
- {% include "../components/error-message.html" with message='Sin datos por mostrar aún' %}
259
- {% endif %}
 
 
 
260
  {% endif %}
261
  </section>
262
  </article>
 
120
  </p>
121
  <article class="flex flex-wrap gap-10 max-sm:gap-2">
122
  {% if not sesion.activo %}
123
+ {% if finished %}
124
+ <div
125
+ class="text-2xl font-semibold flex-1 cts-btn-secondary p-4 flex justify-center items-center rounded-lg select-none text-center">
126
+ <p class=" text-black">
127
+ Sesión finalizada
128
+ </p>
129
+ </div>
130
+ {% else %}
131
  <button
132
  class="ct-btn-start-repition flex-1 uppercase text-lg max-sm:text-base tracking-wider p-4 border-b-2 active:border-b-0 active:border-t-2 active:border-green-500 border-green-800 transition-all rounded-xl bg-green-600 text-white font-bold disabled:bg-amber-600 flex flex-col justify-center items-center gap-2"
133
  onclick="startRepetition()">
 
136
  <img src="{% static 'img/giro.svg' %}" alt="flechas girando" class="invert">
137
  </figure>
138
  </button>
139
+ {% endif %}
140
  {% else %}
141
  <a href="{% url 'cata_system:monitor_sesion' session_code=sesion.codigo_sesion %}" class="flex-1 w-fit">
142
  <button
 
241
  </article>
242
 
243
  {% if repeticion == 1 %}
244
+ {% if data_ratings %}
245
+ {% include "../components/table_pf_all.html" with second_phase=second_phase data_ratings=data_ratings %}
 
 
 
246
  {% else %}
247
+ {% include "../components/error-message.html" with message='Sin datos por mostrar aún' %}
248
+ {% endif %}
249
+ {% else %}
250
+ {% if data_ratings %}
251
+ <article class="overflow-x-auto py-2 space-y-4">
252
+ {% for data_tester in data_ratings %}
253
+ {% include "../components/table_pf.html" with data=data_tester session_name=sesion.nombre_sesion session_code=sesion.codigo_sesion %}
254
+ {% endfor %}
255
+ </article>
256
+ <div class="flex justify-end mt-3 gap-4 flex-wrap">
257
+ <button id="download-csv-btn" class="cts-btn-general cts-btn-primary btn-push">
258
+ Descargar datos CSV en zip
259
+ </button>
260
+ <button id="download-xls-btn" class="cts-btn-general cts-btn-primary btn-push">
261
+ Descargar datos en XLSX
262
+ </button>
263
+ </div>
264
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
265
+ <script src="{% static 'js/download-table-xlsx.js' %}"></script>
266
+ {% else %}
267
+ {% include "../components/error-message.html" with message='Sin datos por mostrar aún' %}
268
+ {% endif %}
269
  {% endif %}
270
  </section>
271
  </article>
tecnicas/urls.py CHANGED
@@ -156,7 +156,7 @@ urlpatterns = [
156
  views.ratingSort,
157
  name="api_rating_sort"),
158
 
159
- path("testers/api/rating-napping/no-mode",
160
- views.ratingNappingNoMode,
161
- name="api_rating_napping_no_mode"),
162
  ]
 
156
  views.ratingSort,
157
  name="api_rating_sort"),
158
 
159
+ path("testers/api/rating-napping",
160
+ views.ratingNapping,
161
+ name="api_rating_napping"),
162
  ]
tecnicas/views/__init__.py CHANGED
@@ -29,7 +29,7 @@ from .apis.api_list_words_pf import apiListWordsPF
29
  from .apis.rating_word_scales import ratingWordScales
30
  from .apis.rating_word_cata import ratingWordCata
31
  from .apis.rating_sort import ratingSort
32
- from .apis.rating_napping import ratingNappingNoMode
33
 
34
  from .tester_forms.init_tester_form import initTesterForm
35
  from .tester_forms.panel_main_tester import mainPanelTester
 
29
  from .apis.rating_word_scales import ratingWordScales
30
  from .apis.rating_word_cata import ratingWordCata
31
  from .apis.rating_sort import ratingSort
32
+ from .apis.rating_napping import ratingNapping
33
 
34
  from .tester_forms.init_tester_form import initTesterForm
35
  from .tester_forms.panel_main_tester import mainPanelTester
tecnicas/views/apis/rating_napping.py CHANGED
@@ -3,7 +3,7 @@ from tecnicas.controllers import RatingNappingController
3
  import json
4
 
5
 
6
- def ratingNappingNoMode(req: HttpRequest):
7
  if req.method == "POST":
8
  try:
9
  data = json.loads(req.body.decode("utf-8"))
 
3
  import json
4
 
5
 
6
+ def ratingNapping(req: HttpRequest):
7
  if req.method == "POST":
8
  try:
9
  data = json.loads(req.body.decode("utf-8"))