diff --git a/bedelia/api/management/commands/load_bedelia_data.py b/bedelia/api/management/commands/load_bedelia_data.py index f360eee..558c5b6 100644 --- a/bedelia/api/management/commands/load_bedelia_data.py +++ b/bedelia/api/management/commands/load_bedelia_data.py @@ -1442,19 +1442,27 @@ def _validate_or_create_posprevia_relationship(self, plan_materia_fuente: PlanMa # 4. Crear o obtener PospreviaItem materia_fuente = plan_materia_fuente.materia descripcion = posprevia.get('descripcion', '') - + fecha = None + fecha_raw = (posprevia.get('fecha') or '').strip() + if fecha_raw: + try: + fecha = datetime.strptime(fecha_raw, '%d/%m/%Y').date() + except ValueError: + fecha = None + try: if self.dry_run: posprevia_item = PosPreviaItem( materia=materia_fuente, plan_estudio=plan_estudio, - descripcion=descripcion + descripcion=descripcion, + fecha=fecha ) else: posprevia_item, created = PosPreviaItem.objects.get_or_create( materia=materia_fuente, plan_estudio=plan_estudio, - defaults={'descripcion': descripcion} + defaults={'descripcion': descripcion, 'fecha': fecha} ) # Agregar unidad dependiente a la relación ManyToMany diff --git a/bedelia/api/migrations/0002_pospreviaitem_fecha.py b/bedelia/api/migrations/0002_pospreviaitem_fecha.py new file mode 100644 index 0000000..4d48fce --- /dev/null +++ b/bedelia/api/migrations/0002_pospreviaitem_fecha.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2026-08-04 00:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='pospreviaitem', + name='fecha', + field=models.DateField(blank=True, null=True, verbose_name='Fecha reportada por Bedelías'), + ), + ] diff --git a/bedelia/api/models.py b/bedelia/api/models.py index 9a0127f..50bafa5 100644 --- a/bedelia/api/models.py +++ b/bedelia/api/models.py @@ -153,6 +153,11 @@ class PosPreviaItem(models.Model): blank=True, verbose_name="Descripción / texto auxiliar", ) + fecha = models.DateField( + null=True, + blank=True, + verbose_name="Fecha reportada por Bedelías", + ) materia = models.ForeignKey( Materia, on_delete=models.CASCADE, diff --git a/bedelia/api/serializers/materias.py b/bedelia/api/serializers/materias.py index 7939201..2d80caf 100644 --- a/bedelia/api/serializers/materias.py +++ b/bedelia/api/serializers/materias.py @@ -106,7 +106,7 @@ class PosPreviaSerializer(serializers.Serializer): anio_plan = serializers.CharField(source='plan_estudio.anio') carrera = serializers.CharField(source='plan_estudio.nombre_carrera') - fecha = serializers.DateTimeField(source='posprevia_item.fecha_creacion', format='%d/%m/%Y') + fecha = serializers.DateField(source='posprevia_item.fecha', format='%d/%m/%Y', allow_null=True) descripcion = serializers.CharField(source='posprevia_item.descripcion') tipo = serializers.CharField(source='unidad_dependiente.tipo') materia_codigo = serializers.CharField(source='materia_dependiente.codigo') diff --git a/bedelia/api/views/materias.py b/bedelia/api/views/materias.py index 06a4df8..f05b119 100644 --- a/bedelia/api/views/materias.py +++ b/bedelia/api/views/materias.py @@ -542,10 +542,10 @@ def get_queryset(self): # Find all PosPreviaItem records for this materia queryset = PosPreviaItem.objects.filter(materia=materia).select_related('plan_estudio').prefetch_related('unidades_dependientes__materia') - # Optional: Filter by unidad_tipo + # Optional: Filter by unidad_tipo (distinct: the M2M join duplicates rows) unidad_tipo = self.request.query_params.get('unidad_tipo') if unidad_tipo: - queryset = queryset.filter(unidades_dependientes__tipo=unidad_tipo) + queryset = queryset.filter(unidades_dependientes__tipo=unidad_tipo).distinct() # Optional: Filter by active status activo = self.request.query_params.get('activo') @@ -559,10 +559,16 @@ def list(self, request, *args, **kwargs): """Override list to flatten unidades_dependientes into separate entries.""" queryset = self.get_queryset() - # Flatten the data - each unidad_dependiente becomes a separate entry + # Flatten the data - each unidad_dependiente becomes a separate entry. + # The unidad_tipo filter must also apply here: filtering the queryset only + # narrows which PosPreviaItem rows come back, not which unidades they emit. + unidad_tipo = request.query_params.get('unidad_tipo') flattened_data = [] for posprevia_item in queryset: - for unidad in posprevia_item.unidades_dependientes.all(): + unidades = posprevia_item.unidades_dependientes.all() + if unidad_tipo: + unidades = [u for u in unidades if u.tipo == unidad_tipo] + for unidad in unidades: entry = { 'posprevia_item': posprevia_item, 'unidad_dependiente': unidad, diff --git a/eligible_courses.py b/eligible_courses.py index 0c6d7dd..e6f1a12 100644 --- a/eligible_courses.py +++ b/eligible_courses.py @@ -88,11 +88,17 @@ def requirement_satisfied(node: Dict[str, Any], ctx: Dict[str, Any]) -> bool: for item in items: modality = item.get("modality") code = item.get("code", "") - if modality in {"course", "ucb_module"}: + if modality in {"course", "ucb_module", "course_enrollment", "exam_enrollment"}: + # An approved exam implies the materia is fully passed, so it also + # satisfies course requirements. Approval of the course covers the + # enrollment modalities (you can only have passed it if you enrolled, + # and a passed course grants access to the exam). if code in ctx["approved_courses"] or code in ctx["approved_exams"]: matched += 1 elif modality == "exam": - if code in ctx["approved_exams"] or code in ctx["approved_courses"]: + # Only a passed exam satisfies an exam requirement; a passed course + # alone does not. + if code in ctx["approved_exams"]: matched += 1 elif modality == "credits_in_plan": needed = item.get("credits_required", 0) diff --git a/scraper/common/usetable.py b/scraper/common/usetable.py index 2b984a3..52c1afd 100644 --- a/scraper/common/usetable.py +++ b/scraper/common/usetable.py @@ -64,11 +64,25 @@ def go_to_page(self, page: int): page_text == f"Page {page}" ): return - if page > 10 and (page_text == "Page 1" or page_text == "1"): - self.scroll_to_element_and_click(self.wait_for_element_to_be_clickable((By.XPATH, f'//a[contains(@class,"ui-paginator-last")]'))) - self.wait_for_page_to_load() + # The paginator only renders a window of ~10 page links. If the target + # link is not visible from the current position, step through blocks + # with the next/prev arrows until it appears (bounded to avoid looping). + target_xpath = f'//a[@aria-label="Page {page}"]' self.wait.until(EC.invisibility_of_element_located((By.XPATH, '//div[@id="j_idt22_modal"]'))) - self.scroll_to_element_and_click(self.wait_for_element_to_be_clickable((By.XPATH, f'//a[@aria-label="Page {page}"]'))) + steps = 0 + while not self.try_find_element((By.XPATH, target_xpath)) and steps < 50: + steps += 1 + current = self.wait_for_element_to_be_visible( + (By.XPATH, '//a[contains(@class, "ui-state-active")]') + ) + current_label = current.get_attribute("aria-label") or current.text.strip() + current_page = int(current_label.replace("Page", "").strip() or 1) + arrow = "ui-paginator-next" if page > current_page else "ui-paginator-prev" + self.scroll_to_element_and_click( + self.wait_for_element_to_be_clickable((By.XPATH, f'//a[contains(@class,"{arrow}")]')) + ) + self.wait_for_page_to_load() + self.scroll_to_element_and_click(self.wait_for_element_to_be_clickable((By.XPATH, target_xpath))) self.wait_for_page_to_load() self.logger.info("Waiting for loading to finish") self.wait.until(EC.invisibility_of_element_located((By.XPATH, "//img[@src='/jakarta.faces.resource/img/cargando.gif.xhtml?ln=default']")))