views.py 23.4 KB
Newer Older
1
import json
2
from datetime import date
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
3 4

import pypandoc
5
from django.conf import settings
6
from django.contrib import messages
7 8 9 10 11
from django.contrib.auth.mixins import (
    LoginRequiredMixin,
    PermissionRequiredMixin,
    UserPassesTestMixin,
)
12 13
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
14
from django.forms.models import modelform_factory
15
from django.http import Http404
16
from django.shortcuts import get_object_or_404, redirect
17
from django.urls import reverse_lazy
18
from django.utils.translation import gettext_lazy as _
19
from django.views.generic import DetailView, RedirectView, TemplateView
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
20
from django.views.generic.edit import CreateView, DeleteView, UpdateView
21
from django_summernote.widgets import SummernoteWidget
22
from django_tables2.views import SingleTableView
23 24
from templated_email import send_templated_mail

25
from .forms import InvitacionForm, ProyectoForm
26
from .models import (
27
    Centro,
28 29 30 31 32 33 34
    Convocatoria,
    Evento,
    ParticipanteProyecto,
    Proyecto,
    Registro,
    TipoParticipacion,
)
35
from .tables import ProyectosTable
36

37

38 39 40 41 42
class ChecksMixin(UserPassesTestMixin):
    """Proporciona comprobaciones para autorizar o no una acción a un usuario."""

    def es_coordinador(self, proyecto_id):
        """Devuelve si el usuario actual es coordinador del proyecto indicado."""
43
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
44 45
        usuario_actual = self.request.user
        coordinadores_participantes = proyecto.participantes.filter(
46
            tipo_participacion__in=["coordinador", "coordinador_2"]
47 48 49 50 51 52 53 54 55 56
        ).all()
        usuarios_coordinadores = list(
            map(lambda p: p.usuario, coordinadores_participantes)
        )
        self.permission_denied_message = _("Usted no es coordinador de este proyecto.")

        return usuario_actual in usuarios_coordinadores

    def es_participante(self, proyecto_id):
        """Devuelve si el usuario actual es participante del proyecto indicado."""
57
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
58
        usuario_actual = self.request.user
59 60 61
        pp = proyecto.participantes.filter(
            usuario=usuario_actual, tipo_participacion="participante"
        ).all()
62 63
        self.permission_denied_message = _("Usted no es participante de este proyecto.")

64
        return True if pp else False
65 66 67

    def es_invitado(self, proyecto_id):
        """Devuelve si el usuario actual es invitado del proyecto indicado."""
68
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
69
        usuario_actual = self.request.user
70 71 72
        pp = proyecto.participantes.filter(
            usuario=usuario_actual, tipo_participacion="invitado"
        ).all()
73 74
        self.permission_denied_message = _("Usted no está invitado a este proyecto.")

75 76 77 78
        return True if pp else False

    def esta_vinculado(self, proyecto_id):
        """Devuelve si el usuario actual está vinculado al proyecto indicado."""
79
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
80
        usuario_actual = self.request.user
81 82 83 84 85
        pp = (
            proyecto.participantes.filter(usuario=usuario_actual)
            .exclude(tipo_participacion="invitacion_rehusada")
            .all()
        )
86 87 88
        self.permission_denied_message = _("Usted no está vinculado a este proyecto.")

        return True if pp else False
89 90

    def es_pas_o_pdi(self):
91 92 93
        """
        Devuelve si el usuario actual es PAS o PDI de la UZ o de sus centros adscritos.
        """
94 95 96 97 98 99
        usuario_actual = self.request.user
        colectivos_del_usuario = json.loads(usuario_actual.colectivos)
        self.permission_denied_message = _("Usted no es PAS ni PDI.")

        return any(
            col_autorizado in colectivos_del_usuario
100
            for col_autorizado in ["PAS", "ADS", "PDI"]
101 102
        )

103
    def es_decano_o_director(self, proyecto_id):
104
        """Devuelve si el usuario actual es decano/director del centro del proyecto."""
105 106 107 108 109 110 111 112 113 114 115 116
        usuario_actual = self.request.user
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        centro = proyecto.centro
        if not centro:
            return False
        nip_decano = centro.nip_decano
        self.permission_denied_message = _(
            "Usted no es decano/director del centro del proyecto."
        )

        return usuario_actual.username == str(nip_decano)

117 118 119 120 121 122 123 124 125 126 127 128 129
    def esta_vinculado_o_es_decano(self, proyecto_id):
        """
        Devuelve si el usuario actual está vinculado al proyecto indicado
        o es decano o director del centro del proyecto."""
        esta_autorizado = self.esta_vinculado(proyecto_id) or self.es_decano_o_director(
            proyecto_id
        )
        self.permission_denied_message = _(
            "Usted no está vinculado a este proyecto, "
            "ni es decano/director del centro del proyecto."
        )
        return esta_autorizado

130

131 132 133 134 135 136
class AyudaView(TemplateView):
    template_name = "ayuda.html"


class HomePageView(TemplateView):
    template_name = "home.html"
137 138


139 140
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
    """Muestra un formulario para invitar a una persona a un proyecto determinado."""
141 142 143 144 145 146 147 148 149 150 151 152 153 154

    form_class = InvitacionForm
    model = ParticipanteProyecto
    template_name = "participante-proyecto/invitar.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        proyecto_id = self.kwargs["proyecto_id"]
        context["proyecto"] = Proyecto.objects.get(id=proyecto_id)
        return context

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs()
        # Update the kwargs for the form init method with ours
155
        kwargs.update(self.kwargs)  # self.kwargs contains all URL conf params
156
        kwargs["request"] = self.request
157 158 159
        return kwargs

    def get_success_url(self, **kwargs):
160 161 162
        return reverse_lazy(
            "proyecto_detail", kwargs={"pk": self.kwargs["proyecto_id"]}
        )
163

164 165 166
    def test_func(self):
        # TODO: Comprobar estado del proyecto, fecha.
        return self.es_coordinador(self.kwargs["proyecto_id"])
167

168

169 170 171 172
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
    """Aceptar la invitación a participar en un proyecto."""

    def get_redirect_url(self, *args, **kwargs):
173
        return reverse_lazy("mis_proyectos", kwargs={"anyo": date.today().year})
174 175

    def post(self, request, *args, **kwargs):
176
        usuario_actual = self.request.user
177 178
        proyecto_id = kwargs.get("proyecto_id")
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
179

180
        num_equipos = usuario_actual.get_num_equipos(proyecto.convocatoria_id)
181
        num_max_equipos = proyecto.convocatoria.num_max_equipos
182 183 184 185 186 187 188 189 190 191 192 193
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
                    f"""No puede aceptar esta invitación porque ya forma parte del número
                máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder aceptar esta invitación, antes debería renunciar a participar
                en algún otro proyecto."""
                ),
            )
            return super().post(request, *args, **kwargs)

194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213
        pp = get_object_or_404(
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion="invitado",
        )
        pp.tipo_participacion_id = "participante"
        pp.save()

        messages.success(
            request,
            _(f"Ha pasado a ser participante del proyecto «{proyecto.titulo}»."),
        )
        return super().post(request, *args, **kwargs)


class ParticipanteDeclinarView(LoginRequiredMixin, RedirectView):
    """Declinar la invitación a participar en un proyecto."""

    def get_redirect_url(self, *args, **kwargs):
214
        return reverse_lazy("mis_proyectos", kwargs={"anyo": date.today().year})
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235

    def post(self, request, *args, **kwargs):
        proyecto_id = request.POST.get("proyecto_id")
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion="invitado",
        )
        pp.tipo_participacion_id = "invitacion_rehusada"
        pp.save()

        messages.success(
            request,
            _(f"Ha rehusado ser participante del proyecto «{proyecto.titulo}»."),
        )
        return super().post(request, *args, **kwargs)


236 237 238 239
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
    """Renunciar a participar en un proyecto."""

    def get_redirect_url(self, *args, **kwargs):
240
        return reverse_lazy("mis_proyectos", kwargs={"anyo": date.today().year})
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261

    def post(self, request, *args, **kwargs):
        proyecto_id = request.POST.get("proyecto_id")
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion="participante",
        )
        pp.tipo_participacion_id = "invitacion_rehusada"
        pp.save()

        messages.success(
            request,
            _(f"Ha renunciado a participar en el proyecto «{proyecto.titulo}»."),
        )
        return super().post(request, *args, **kwargs)


262 263
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
    """Borra un registro de ParticipanteProyecto"""
264 265 266 267 268 269 270

    model = ParticipanteProyecto
    template_name = "participante-proyecto/confirm_delete.html"

    def get_success_url(self):
        return reverse_lazy("proyecto_detail", args=[self.object.proyecto.id])

271 272
    def test_func(self):
        return self.es_coordinador(self.get_object().proyecto.id)
273

274

275 276
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
    """Crea una nueva solicitud de proyecto"""
277

278 279
    model = Proyecto
    template_name = "proyecto/new.html"
280
    form_class = ProyectoForm
281 282

    def form_valid(self, form):
283 284
        # This method is called when valid form data has been POSTed,
        # to do custom logic on form data. It should return an HttpResponse.
285 286 287 288 289
        proyecto = form.save()
        self._guardar_coordinador(proyecto)
        self._registrar_creacion(proyecto)
        return redirect("proyecto_detail", proyecto.id)

290
    def get_form(self, form_class=None):
291 292
        """
        Devuelve el formulario añadiendo automáticamente el campo Convocatoria,
293
        que es requerido, y el usuario, para comprobar si tiene los permisos necesarios.
294
        """
295
        form = super(ProyectoCreateView, self).get_form(form_class)
296
        form.instance.user = self.request.user
297 298 299
        form.instance.convocatoria = Convocatoria(date.today().year)
        return form

300
    def _guardar_coordinador(self, proyecto):
301
        pp = ParticipanteProyecto(
302
            proyecto=proyecto,
303
            tipo_participacion=TipoParticipacion(nombre="coordinador"),
304 305
            usuario=self.request.user,
        )
306
        pp.save()
307 308 309 310 311 312 313 314 315 316

    def _registrar_creacion(self, proyecto):
        evento = Evento.objects.get(nombre="creacion_solicitud")
        registro = Registro(
            descripcion="Creación inicial de la solicitud",
            evento=evento,
            proyecto=proyecto,
        )
        registro.save()

317 318 319 320 321
    def test_func(self):
        # TODO: Comprobar usuario para Proyectos de titulación y POU.
        # TODO: Comprobar fecha
        return self.es_pas_o_pdi()

322

323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
    """
    Cambia el estado de una solicitud de proyecto a Anulada.
    """

    model = Proyecto

    def get_redirect_url(self, *args, **kwargs):
        return reverse_lazy("mis_proyectos", kwargs={"anyo": date.today().year})

    def post(self, request, *args, **kwargs):
        proyecto = Proyecto.objects.get(pk=kwargs.get("pk"))
        proyecto.estado = "ANULADO"
        proyecto.save()

        messages.success(request, _("Su solicitud de proyecto ha sido anulada."))
        return super().post(request, *args, **kwargs)

    def test_func(self):
        return self.es_coordinador(self.kwargs["pk"])


345
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
346 347
    """Muestra una solicitud de proyecto."""

348 349 350 351 352 353
    model = Proyecto
    template_name = "proyecto/detail.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

354 355
        pp_coordinador = self.object.get_participante_or_none("coordinador")
        context["pp_coordinador"] = pp_coordinador
356

357 358
        pp_coordinador_2 = self.object.get_participante_or_none("coordinador_2")
        context["pp_coordinador_2"] = pp_coordinador_2
359

360 361 362 363 364 365 366 367
        participantes = (
            self.object.participantes.filter(tipo_participacion="participante")
            .order_by("usuario__first_name", "usuario__last_name")
            .all()
        )
        context["participantes"] = participantes

        invitados = (
368 369 370 371
            self.object.participantes.filter(
                tipo_participacion__in=["invitado", "invitacion_rehusada"]
            )
            .order_by("tipo_participacion", "usuario__first_name", "usuario__last_name")
372 373 374 375
            .all()
        )
        context["invitados"] = invitados

376 377
        context["campos"] = json.loads(self.object.programa.campos)

378 379 380 381
        context["permitir_edicion"] = (
            self.es_coordinador(self.object.id) and self.object.en_borrador()
        )

382
        return context
383

384 385
    def test_func(self):
        # TODO: Los evaluadores y gestores también tendrán que tener acceso.
386
        proyecto_id = self.kwargs["pk"]
387
        return self.esta_vinculado_o_es_decano(proyecto_id)
388 389


390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410
class ProyectoListView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""

    permission_required = "indo.listar_proyectos"
    permission_denied_message = _("Sólo los gestores pueden acceder a esta página.")
    table_class = ProyectosTable
    template_name = "gestion/proyecto/tabla.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["anyo"] = self.kwargs["anyo"]
        return context

    def get_queryset(self):
        return (
            Proyecto.objects.filter(convocatoria__id=self.kwargs["anyo"])
            .exclude(estado__in=["BORRADOR", "ANULADO"])
            .order_by("programa__nombre_corto", "linea__nombre", "titulo")
        )


411 412
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
    """Presenta una solicitud de proyecto.
413

414
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
415
    Se envían correos a los agentes involucrados.
416
    """
417

418 419 420 421 422 423 424 425
    def get_redirect_url(self, *args, **kwargs):
        return reverse_lazy("proyecto_detail", args=[kwargs.get("pk")])

    def post(self, request, *args, **kwargs):
        proyecto_id = kwargs.get("pk")
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
426

427
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
428
        num_max_equipos = proyecto.convocatoria.num_max_equipos
429 430 431 432 433 434 435 436 437 438 439 440
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
                    f"""No puede presentar esta solicitud porque ya forma parte
                del número máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder presentar esta solicitud de proyecto, antes debería renunciar
                a participar en algún otro proyecto."""
                ),
            )
            return super().post(request, *args, **kwargs)

441 442 443 444 445 446 447 448
        if request.user.get_colectivo_principal() == "ADS" and proyecto.ayuda != 0:
            messages.error(
                request,
                _(
                    "Los profesores de los centros adscritos no pueden coordinar "
                    "proyectos con financiación."
                ),
            )
449 450
            return super().post(request, *args, **kwargs)

451
        if proyecto.ayuda > proyecto.programa.max_ayuda:
452 453 454 455
            messages.error(
                request,
                _(
                    f"La ayuda solicitada ({proyecto.ayuda} €) excede el máximo "
456
                    f"permitido para este programa ({proyecto.programa.max_ayuda} €)."
457 458 459 460 461 462 463 464 465
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
            messages.error(
                request,
                _("La solicitud debe incluir al menos un invitado a participar."),
            )
466
            return super().post(request, *args, **kwargs)
467

468 469 470
        self._enviar_invitaciones(request, proyecto)
        if proyecto.programa.nombre_corto in ["PIEC", "PRACUZ"]:
            self._enviar_solicitudes_visto_bueno(request, proyecto)
471
        # TODO Enviar "resguardo" al solicitante. PDF?
472 473 474 475 476 477 478 479 480

        proyecto.estado = "SOLICITADO"
        proyecto.save()

        # TODO Modificar detail.html para no mostrar botones de edición/presentación
        messages.success(request, _("Su solicitud de proyecto ha sido presentada."))
        return super().post(request, *args, **kwargs)

    def _enviar_invitaciones(self, request, proyecto):
481
        """Envia un mensaje a cada uno de los invitados al proyecto."""
482 483 484 485 486 487 488 489 490 491
        for invitado in proyecto.participantes.filter(tipo_participacion="invitado"):
            send_templated_mail(
                template_name="invitacion",
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
                    "nombre_coordinador": request.user.get_full_name(),
                    "nombre_invitado": invitado.usuario.get_full_name(),
                    "sexo_invitado": invitado.usuario.sexo,
                    "titulo_proyecto": proyecto.titulo,
492 493
                    "programa_proyecto": f"{proyecto.programa.nombre_corto} "
                    + f"({proyecto.programa.nombre_largo})",
494 495
                    "descripcion_proyecto": pypandoc.convert_text(
                        proyecto.descripcion, "md", format="html"
496
                    ).replace("\\\n", "  \n"),
497
                    "site_url": settings.SITE_URL,
498 499 500
                },
            )

501
    def _enviar_solicitudes_visto_bueno(self, request, proyecto):
502
        """Envia un mensaje al responsable del centro solicitando su visto bueno."""
503 504 505 506 507 508 509 510 511 512 513 514
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
                request,
                _(
                    "La dirección de correo electrónico del director o decano "
                    "del centro no es válida."
                ),
            )
            return

515 516 517 518 519 520 521 522 523
        send_templated_mail(
            template_name="solicitud_visto_bueno",
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
                "nombre_coordinador": request.user.get_full_name(),
                "nombre_decano": proyecto.centro.nombre_decano,
                "tratamiento_decano": proyecto.centro.tratamiento_decano,
                "titulo_proyecto": proyecto.titulo,
524 525
                "programa_proyecto": f"{proyecto.programa.nombre_corto} "
                f"({proyecto.programa.nombre_largo})",
526 527 528
                "descripcion_proyecto": pypandoc.convert_text(
                    proyecto.descripcion, "md", format="html"
                ).replace("\\\n", "\n"),
529 530 531
                "site_url": settings.SITE_URL,
            },
        )
532

533 534 535
    def test_func(self):
        # TODO: Comprobar fecha
        return self.es_coordinador(self.kwargs["pk"])
536

537 538

class ProyectoUpdateFieldView(LoginRequiredMixin, ChecksMixin, UpdateView):
539
    """Actualiza un campo de una solicitud de proyecto."""
540

541 542 543 544 545 546
    # TODO: Comprobar estado/fecha
    model = Proyecto
    template_name = "proyecto/update.html"

    def get_form_class(self, **kwargs):
        campo = self.kwargs["campo"]
547
        if campo in (
548
            "centro",
549
            "codigo",
550
            "convocatoria",
551 552
            "estado",
            "estudio",
553 554 555
            "linea",
            "programa",
        ):
556 557
            raise Http404(_("No puede editar ese campo."))

558
        if campo not in ("titulo", "departamento", "licencia", "ayuda", "visto_bueno"):
559
            formulario = modelform_factory(
560 561
                Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()}
            )
562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581

            def as_p(self):
                """
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
                """
                return self._html_output(
                    normal_row="""<p%(html_class_attr)s>
                    %(label)s
                    %(help_text)s
                    %(field)s
                    </p>""",
                    error_row="%s",
                    row_ender="</p>",
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
            return formulario
582 583
        self.fields = (campo,)
        return super().get_form_class()
584

585
    def test_func(self):
586 587 588 589
        return self.es_coordinador(self.kwargs["pk"]) or (
            self.kwargs["campo"] == "visto_bueno"
            and self.es_decano_o_director(self.kwargs["pk"])
        )
590

591

592 593
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
    """Lista los proyectos a los que está vinculado el usuario actual."""
594

595 596 597
    template_name = "proyecto/mis-proyectos.html"

    def get_context_data(self, **kwargs):
598
        usuario = self.request.user
599
        anyo = self.kwargs["anyo"]
600
        context = super().get_context_data(**kwargs)
601 602
        context["proyectos_coordinados"] = (
            Proyecto.objects.filter(
603
                convocatoria__id=anyo,
604 605 606
                participantes__usuario=usuario,
                participantes__tipo_participacion_id__in=[
                    "coordinador",
607
                    "coordinador_2",
608 609
                ],
            )
610
            .exclude(estado="ANULADO")
611 612 613 614 615
            .order_by("programa__nombre_corto", "linea__nombre", "titulo")
            .all()
        )
        context["proyectos_participados"] = (
            Proyecto.objects.filter(
616
                convocatoria__id=anyo,
617 618 619 620 621 622 623 624
                participantes__usuario=usuario,
                participantes__tipo_participacion_id="participante",
            )
            .order_by("programa__nombre_corto", "linea__nombre", "titulo")
            .all()
        )
        context["proyectos_invitado"] = (
            Proyecto.objects.filter(
625
                convocatoria__id=anyo,
626 627 628 629 630 631
                participantes__usuario=usuario,
                participantes__tipo_participacion_id="invitado",
            )
            .order_by("programa__nombre_corto", "linea__nombre", "titulo")
            .all()
        )
632

633 634 635 636 637 638 639 640 641 642 643 644 645
        try:
            nip_decano = int(usuario.username)
        except ValueError:
            nip_decano = 0

        centros_dirigidos = Centro.objects.filter(nip_decano=nip_decano).all()
        if centros_dirigidos:
            context["proyectos_centros_dirigidos"] = Proyecto.objects.filter(
                convocatoria_id=anyo,
                programa__requiere_visto_bueno=True,
                centro__in=centros_dirigidos,
            ).all()

646
        return context