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

6 7 8 9 10
# Third-party
from annoying.functions import get_config, get_object_or_None
from django_summernote.widgets import SummernoteWidget
from django_tables2.views import SingleTableView
from templated_email import send_templated_mail
11
import bleach
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
12
import pypandoc
13

14
# Django
15
from django.conf import settings
16
from django.contrib import messages
17 18 19 20 21 22 23
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import (
    LoginRequiredMixin,
    PermissionRequiredMixin,
    UserPassesTestMixin,
)
from django.contrib.auth.models import Group
24
from django.contrib.messages.views import SuccessMessageMixin
25 26
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
27
from django.forms.models import modelform_factory
28
from django.http import Http404, HttpResponse
29
from django.shortcuts import get_object_or_404, redirect
30
from django.urls import reverse, reverse_lazy
31
from django.utils.safestring import mark_safe
32
from django.utils.translation import gettext_lazy as _
33
from django.views import View
34
from django.views.generic import DetailView, RedirectView, TemplateView
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
35
from django.views.generic.edit import CreateView, DeleteView, UpdateView
36

37
# Local Django
38
from .forms import EvaluadorForm, InvitacionForm, ProyectoForm, ResolucionForm
39 40 41
from .models import (
    Centro,
    Convocatoria,
42
    Criterio,
43 44 45 46 47 48
    Evento,
    ParticipanteProyecto,
    Plan,
    Proyecto,
    Registro,
    TipoParticipacion,
49
    Valoracion,
50
)
51 52 53 54 55 56
from .tables import (
    EvaluadoresTable,
    EvaluacionProyectosTable,
    ProyectosEvaluadosTable,
    ProyectosTable,
)
57

58

59
class ChecksMixin(UserPassesTestMixin):
60
    """Proporciona comprobaciones para autorizar o no una acción a un usuario."""
61 62

    def es_coordinador(self, proyecto_id):
63
        """Devuelve si el usuario actual es coordinador del proyecto indicado."""
64
        self.permission_denied_message = _('Usted no es coordinador de este proyecto.')
65
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
66 67
        usuario_actual = self.request.user
        coordinadores_participantes = proyecto.participantes.filter(
68
            tipo_participacion__in=['coordinador', 'coordinador_2']
69
        ).all()
70
        usuarios_coordinadores = list(map(lambda p: p.usuario, coordinadores_participantes))
71 72 73 74

        return usuario_actual in usuarios_coordinadores

    def es_participante(self, proyecto_id):
75
        """Devuelve si el usuario actual es participante del proyecto indicado."""
76
        self.permission_denied_message = _('Usted no es participante de este proyecto.')
77
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
78
        usuario_actual = self.request.user
79 80 81
        pp = proyecto.participantes.filter(
            usuario=usuario_actual, tipo_participacion='participante'
        ).all()
82

83
        return True if pp else False
84 85

    def es_invitado(self, proyecto_id):
86
        """Devuelve si el usuario actual es invitado del proyecto indicado."""
87
        self.permission_denied_message = _('Usted no está invitado a este proyecto.')
88
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
89
        usuario_actual = self.request.user
90 91 92
        pp = proyecto.participantes.filter(
            usuario=usuario_actual, tipo_participacion='invitado'
        ).all()
93

94 95 96
        return True if pp else False

    def esta_vinculado(self, proyecto_id):
97
        """Devuelve si el usuario actual está vinculado al proyecto indicado."""
98
        self.permission_denied_message = _('Usted no está vinculado a este proyecto.')
99
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
100
        usuario_actual = self.request.user
101 102
        pp = (
            proyecto.participantes.filter(usuario=usuario_actual)
103
            .exclude(tipo_participacion='invitacion_rehusada')
104 105
            .all()
        )
106 107

        return True if pp else False
108 109

    def es_pas_o_pdi(self):
110
        """Devuelve si el usuario actual es PAS o PDI de la UZ o de sus centros adscritos."""
111
        self.permission_denied_message = _('Usted no es PAS ni PDI.')
112 113 114
        usuario_actual = self.request.user
        colectivos_del_usuario = json.loads(usuario_actual.colectivos)

115 116 117
        return any(
            col_autorizado in colectivos_del_usuario for col_autorizado in ['PAS', 'ADS', 'PDI']
        )
118

119
    def es_decano_o_director(self, proyecto_id):
120
        """Devuelve si el usuario actual es decano/director del centro del proyecto."""
121
        self.permission_denied_message = _('Usted no es decano/director del centro del proyecto.')
122
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
123
        usuario_actual = self.request.user
124 125 126 127
        centro = proyecto.centro
        if not centro:
            return False

128
        nip_decano = centro.nip_decano
129 130
        return usuario_actual.username == str(nip_decano)

131
    def esta_vinculado_o_es_decano_o_es_coordinador(self, proyecto_id):
132
        """
133
        Devuelve si el usuario actual está vinculado al proyecto indicado
134
        o es decano o director del centro del proyecto
135
        o es coordinador del plan de estudios del proyecto."""
136 137 138 139 140
        self.permission_denied_message = _(
            'Usted no está vinculado a este proyecto, '
            'ni es decano/director del centro del proyecto, '
            'ni es coordinador del plan de estudios del proyecto.'
        )
141 142 143 144
        usuario_actual = self.request.user
        esta_autorizado = (
            self.esta_vinculado(proyecto_id)
            or self.es_decano_o_director(proyecto_id)
145
            or self.es_coordinador_estudio(proyecto_id)
146
            or usuario_actual.has_perm('indo.ver_proyecto')  # Gestores y evaluadores
147
        )
148

149 150
        return esta_autorizado

151
    def es_coordinador_estudio(self, proyecto_id):
152
        """Devuelve si el usuario actual es coordinador del estudio del proyecto."""
153 154 155
        self.permission_denied_message = _(
            'Usted no es coordinador del plan de estudios del proyecto.'
        )
156
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
157
        usuario_actual = self.request.user
158 159 160
        estudio = proyecto.estudio
        if not estudio:
            return False
161

162 163 164
        nip_coordinadores = [
            f'{p.nip_coordinador}' for p in estudio.planes.all() if p.nip_coordinador
        ]
165
        return usuario_actual.username in nip_coordinadores
166

167 168 169 170 171
    def es_evaluador_del_proyecto(self, proyecto_id):
        """Devuelve si el usuario actual es evaluador del proyecto indicado."""
        self.permission_denied_message = _('Usted no es evaluador de este proyecto.')
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
172

173
        return usuario_actual == proyecto.evaluador
174

175

176
class AyudaView(TemplateView):
177
    template_name = 'ayuda.html'
178 179


180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198
class EvaluacionVerView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
    """Muestra la evaluación del proyecto indicado."""

    permission_required = 'indo.ver_evaluacion'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    template_name = 'gestion/evaluacion.html'

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

        proyecto = get_object_or_404(Proyecto, pk=kwargs['pk'])
        context['proyecto'] = proyecto
        context['criterios'] = Criterio.objects.filter(
            convocatoria_id=proyecto.convocatoria_id
        ).all()
        context['dict_valoraciones'] = proyecto.get_dict_valoraciones()
        return context


199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
class EvaluacionView(LoginRequiredMixin, ChecksMixin, TemplateView):
    """Muestra y permite editar las valoraciones de un proyecto."""

    template_name = 'evaluador/evaluacion.html'

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

        proyecto = get_object_or_404(Proyecto, pk=kwargs['pk'])
        context['proyecto'] = proyecto
        context['criterios'] = Criterio.objects.filter(
            convocatoria_id=proyecto.convocatoria_id
        ).all()
        context['dict_valoraciones'] = proyecto.get_dict_valoraciones()
        return context

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

        proyecto = get_object_or_404(Proyecto, pk=kwargs['pk'])
        criterios = Criterio.objects.filter(convocatoria_id=proyecto.convocatoria_id).all()
        dict_valoraciones = proyecto.get_dict_valoraciones()

        for criterio in criterios:
            valoracion = dict_valoraciones.get(criterio.id)
            if not valoracion:
                valoracion = Valoracion(proyecto_id=proyecto.id, criterio_id=criterio.id)

            if criterio.tipo == 'opcion':
                valoracion.opcion_id = request.POST.get(str(criterio.id))
            elif criterio.tipo == 'texto':
                valoracion.texto = request.POST.get(str(criterio.id))
            valoracion.save()

233 234 235 236
        messages.success(
            request, _(f'Se ha guardado la evaluación del proyecto «{proyecto.titulo}».')
        )
        return redirect('proyectos_evaluados_table', proyecto.convocatoria_id)
237 238 239 240 241

    def test_func(self):
        return self.es_evaluador_del_proyecto(self.kwargs['pk'])


242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
class ProyectoEvaluadorTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla con las solicitudes de proyectos presentadas y el evaluador asignado."""

    permission_required = 'indo.listar_evaluadores'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    table_class = EvaluadoresTable
    template_name = 'gestion/proyecto/tabla_evaluadores.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')
        )


class ProyectosEvaluadosTableView(LoginRequiredMixin, UserPassesTestMixin, SingleTableView):
    """Lista los proyectos asignados al usuario (evaluador) actual."""

    permission_denied_message = _('Sólo los evaluadores pueden acceder a esta página.')
    table_class = ProyectosEvaluadosTable
    template_name = 'evaluador/mis_proyectos.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'])
            .filter(evaluador=self.request.user)
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
        )

    def test_func(self):
        return self.request.user.groups.filter(name='Evaluadores').exists()


class ProyectoEvaluadorUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    """Actualizar el evaluador de un proyecto."""

    permission_required = 'indo.editar_evaluador'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    model = Proyecto
    template_name = 'gestion/proyecto/editar_evaluador.html'
    form_class = EvaluadorForm

    def get(self, request, *args, **kwargs):
296
        User = get_user_model()
297
        # Obtenemos los NIPs de los usuarios con vinculación «Evaluador externo innovacion ACPUA».
298 299 300 301
        nip_evaluadores = User.get_nips_vinculacion(60)
        nip_evaluadores = [str(nip) for nip in nip_evaluadores]
        # nip_evaluadores = ['136040', '327618', '329639', '370109']  # XXX - Desarrollo

302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321
        # Creamos los usuarios que no existan ya en la aplicación.
        evaluadores = Group.objects.get(name='Evaluadores')
        for nip in nip_evaluadores:
            usuario = get_object_or_None(User, username=nip)
            if not usuario:
                usuario = User.crear_usuario(request, nip)
            # Añadimos los usuarios al grupo Evaluadores.
            evaluadores.user_set.add(usuario)  # or usuario.groups.add(evaluadores)

        # Quitamos del grupo Evaluadores a los usuarios que ya no tengan esa vinculación.
        for usuario in evaluadores.user_set.all():
            if usuario.username not in nip_evaluadores:
                evaluadores.user_set.remove(usuario)  # or usuario.groups.remove(evaluadores)

        return super().get(request, *args, **kwargs)

    def get_success_url(self):
        return reverse('evaluadores_table', kwargs={'anyo': self.object.convocatoria})


322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
class ProyectoResolucionUpdateView(
    LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView
):
    """Actualizar la resolución de la Comisión Evaluadora sobre un proyecto."""

    permission_required = 'indo.editar_resolucion'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    model = Proyecto
    success_message = _(
        'Se ha guardado la resolución de la comisión sobre el proyecto «%(titulo)s».'
    )
    template_name = 'gestion/proyecto/editar_resolucion.html'
    form_class = ResolucionForm

    def get_success_message(self, cleaned_data):
        return self.success_message % dict(cleaned_data, titulo=self.object.titulo)

    def get_success_url(self):
        return reverse_lazy('evaluaciones_table', args=[self.object.convocatoria_id])


343
class HomePageView(TemplateView):
344
    template_name = 'home.html'
345 346


347
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
348
    """Muestra un formulario para invitar a una persona a un proyecto determinado."""
349 350 351

    form_class = InvitacionForm
    model = ParticipanteProyecto
352
    template_name = 'participante-proyecto/invitar.html'
353 354 355

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
356 357
        proyecto_id = self.kwargs['proyecto_id']
        context['proyecto'] = Proyecto.objects.get(id=proyecto_id)
358 359 360 361 362
        return context

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs()
        # Update the kwargs for the form init method with ours
363
        kwargs.update(self.kwargs)  # self.kwargs contains all URL conf params
364
        kwargs['request'] = self.request
365 366 367
        return kwargs

    def get_success_url(self, **kwargs):
368
        return reverse_lazy('proyecto_detail', kwargs={'pk': self.kwargs['proyecto_id']})
369

370 371
    def test_func(self):
        # TODO: Comprobar estado del proyecto, fecha.
372 373 374
        return self.es_coordinador(self.kwargs['proyecto_id']) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
375

376

377
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
378
    """Aceptar la invitación a participar en un proyecto."""
379 380

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

    def post(self, request, *args, **kwargs):
384
        usuario_actual = self.request.user
385
        proyecto_id = kwargs.get('proyecto_id')
386
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
387

388
        num_equipos = usuario_actual.get_num_equipos(proyecto.convocatoria_id)
389
        num_max_equipos = proyecto.convocatoria.num_max_equipos
390 391 392 393
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
394
                    f'''No puede aceptar esta invitación porque ya forma parte del número
395 396
                máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder aceptar esta invitación, antes debería renunciar a participar
397
                en algún otro proyecto.'''
398 399 400 401
                ),
            )
            return super().post(request, *args, **kwargs)

402
        pp = get_object_or_404(
403 404 405 406
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
407
        )
408
        pp.tipo_participacion_id = 'participante'
409 410
        pp.save()

411 412 413
        messages.success(
            request, _(f'Ha pasado a ser participante del proyecto «{proyecto.titulo}».')
        )
414 415 416 417
        return super().post(request, *args, **kwargs)


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

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

    def post(self, request, *args, **kwargs):
424
        proyecto_id = request.POST.get('proyecto_id')
425 426 427
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
428 429 430 431
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
432
        )
433
        pp.tipo_participacion_id = 'invitacion_rehusada'
434 435
        pp.save()

436 437 438
        messages.success(
            request, _(f'Ha rehusado ser participante del proyecto «{proyecto.titulo}».')
        )
439 440 441
        return super().post(request, *args, **kwargs)


442
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
443
    """Renunciar a participar en un proyecto."""
444 445

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

    def post(self, request, *args, **kwargs):
449
        proyecto_id = request.POST.get('proyecto_id')
450 451 452
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
453 454 455 456
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='participante',
457
        )
458
        pp.tipo_participacion_id = 'invitacion_rehusada'
459 460
        pp.save()

461 462 463
        messages.success(
            request, _(f'Ha renunciado a participar en el proyecto «{proyecto.titulo}».')
        )
464 465 466
        return super().post(request, *args, **kwargs)


467
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
468
    """Borra un registro de ParticipanteProyecto"""
469 470

    model = ParticipanteProyecto
471
    template_name = 'participante-proyecto/confirm_delete.html'
472 473

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

476
    def test_func(self):
477 478 479
        return self.es_coordinador(self.get_object().proyecto.id) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
480

481

482
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
483
    """Crea una nueva solicitud de proyecto"""
484

485
    model = Proyecto
486
    template_name = 'proyecto/new.html'
487
    form_class = ProyectoForm
488 489

    def form_valid(self, form):
490 491
        # This method is called when valid form data has been POSTed,
        # to do custom logic on form data. It should return an HttpResponse.
492 493 494
        proyecto = form.save()
        self._guardar_coordinador(proyecto)
        self._registrar_creacion(proyecto)
495
        return redirect('proyecto_detail', proyecto.id)
496

497
    def get_form(self, form_class=None):
498
        """
499
        Devuelve el formulario añadiendo automáticamente el campo Convocatoria,
500
        que es requerido, y el usuario, para comprobar si tiene los permisos necesarios.
501
        """
502
        form = super(ProyectoCreateView, self).get_form(form_class)
503
        form.instance.user = self.request.user
504 505 506
        form.instance.convocatoria = Convocatoria(date.today().year)
        return form

507
    def _guardar_coordinador(self, proyecto):
508
        pp = ParticipanteProyecto(
509 510 511
            proyecto=proyecto,
            tipo_participacion=TipoParticipacion(nombre='coordinador'),
            usuario=self.request.user,
512
        )
513
        pp.save()
514 515

    def _registrar_creacion(self, proyecto):
516
        evento = Evento.objects.get(nombre='creacion_solicitud')
517 518 519
        registro = Registro(
            descripcion='Creación inicial de la solicitud', evento=evento, proyecto=proyecto
        )
520 521
        registro.save()

522 523 524 525 526
    def test_func(self):
        # TODO: Comprobar usuario para Proyectos de titulación y POU.
        # TODO: Comprobar fecha
        return self.es_pas_o_pdi()

527

528
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
529
    """Cambia el estado de una solicitud de proyecto a Anulada."""
530 531 532 533

    model = Proyecto

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

    def post(self, request, *args, **kwargs):
537 538
        proyecto = Proyecto.objects.get(pk=kwargs.get('pk'))
        proyecto.estado = 'ANULADO'
539 540
        proyecto.save()

541
        messages.success(request, _('Su solicitud de proyecto ha sido anulada.'))
542 543 544
        return super().post(request, *args, **kwargs)

    def test_func(self):
545
        return self.es_coordinador(self.kwargs['pk'])
546 547


548
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
549
    """Muestra una solicitud de proyecto."""
550

551
    model = Proyecto
552
    template_name = 'proyecto/detail.html'
553 554 555 556

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

557 558
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
559

560 561
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
562

563
        participantes = (
564 565
            self.object.participantes.filter(tipo_participacion='participante')
            .order_by('usuario__first_name', 'usuario__last_name')
566 567
            .all()
        )
568
        context['participantes'] = participantes
569 570

        invitados = (
571 572 573
            self.object.participantes.filter(
                tipo_participacion__in=['invitado', 'invitacion_rehusada']
            )
574
            .order_by('tipo_participacion', 'usuario__first_name', 'usuario__last_name')
575 576
            .all()
        )
577
        context['invitados'] = invitados
578

579
        context['campos'] = json.loads(self.object.programa.campos)
580

581
        context['permitir_edicion'] = (
582
            self.es_coordinador(self.object.id) and self.object.en_borrador()
583
        ) or self.request.user.has_perm('indo.editar_proyecto')
584

585 586 587
        context['es_coordinador'] = (
            self.es_coordinador(self.object.id) and self.object.en_borrador()
        )
588

589
        return context
590

591
    def test_func(self):
592
        proyecto_id = self.kwargs['pk']
593
        return self.esta_vinculado_o_es_decano_o_es_coordinador(proyecto_id)
594 595


596 597 598 599 600 601 602 603 604 605 606 607 608 609 610
class ProyectoEvaluacionesCsvView(LoginRequiredMixin, PermissionRequiredMixin, View):
    """Devuelve un fichero CSV con las valoraciones de todos los proyectos presentados."""

    permission_required = 'indo.listar_evaluaciones'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')

    def get(self, request, *args, **kwargs):
        valoraciones = Valoracion.get_todas(kwargs.get('anyo'))
        response = HttpResponse(content_type='text/csv')
        response['Content-Disposition'] = 'attachment; filename="valoraciones.csv"'
        writer = csv.writer(response)
        writer.writerows(valoraciones)
        return response


611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631
class ProyectoEvaluacionesTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra los proyectos presentados y enlaces a su evaluación y resolución de la Comisión."""

    permission_required = 'indo.listar_evaluaciones'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    table_class = EvaluacionProyectosTable
    template_name = 'gestion/proyecto/tabla_evaluaciones.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')
        )


632 633
class ProyectoTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""
634

635 636
    permission_required = 'indo.listar_proyectos'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
637
    table_class = ProyectosTable
638
    template_name = 'gestion/proyecto/tabla_proyectos.html'
639 640 641

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
642
        context['anyo'] = self.kwargs['anyo']
643 644 645 646
        return context

    def get_queryset(self):
        return (
647 648 649
            Proyecto.objects.filter(convocatoria__id=self.kwargs['anyo'])
            .exclude(estado__in=['BORRADOR', 'ANULADO'])
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
650 651 652
        )


653
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
654
    """Presenta una solicitud de proyecto.
655

656
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
657
    Se envían correos a los agentes involucrados.
658
    """
659

660
    def get_redirect_url(self, *args, **kwargs):
661
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
662 663

    def post(self, request, *args, **kwargs):
664
        proyecto_id = kwargs.get('pk')
665 666 667
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
668

669
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
670
        num_max_equipos = proyecto.convocatoria.num_max_equipos
671 672 673 674
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
675
                    f'''No puede presentar esta solicitud porque ya forma parte
676 677 678
                    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.'''
679 680 681 682
                ),
            )
            return super().post(request, *args, **kwargs)

683
        if request.user.get_colectivo_principal() == 'ADS' and proyecto.ayuda != 0:
684 685
            messages.error(
                request,
686 687 688 689
                _(
                    'Los profesores de los centros adscritos no pueden coordinar '
                    'proyectos con financiación.'
                ),
690
            )
691 692
            return super().post(request, *args, **kwargs)

693
        if proyecto.ayuda > proyecto.programa.max_ayuda:
694 695 696
            messages.error(
                request,
                _(
697 698
                    f'La ayuda solicitada ({proyecto.ayuda} €) excede el máximo '
                    f'permitido para este programa ({proyecto.programa.max_ayuda} €).'
699 700 701 702 703
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
704 705 706
            messages.error(
                request, _('La solicitud debe incluir al menos un invitado a participar.')
            )
707
            return super().post(request, *args, **kwargs)
708

709
        self._enviar_invitaciones(request, proyecto)
710 711 712 713 714 715 716

        if proyecto.programa.requiere_visto_bueno_centro:
            self._enviar_solicitudes_visto_bueno_centro(request, proyecto)

        if proyecto.programa.requiere_visto_bueno_estudio:
            self._enviar_solicitudes_visto_bueno_estudio(request, proyecto)

717
        # TODO Enviar "resguardo" al solicitante. PDF?
718

719
        proyecto.estado = 'SOLICITADO'
720 721 722
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
727
        """Envia un mensaje a cada uno de los invitados al proyecto."""
728
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
729
            send_templated_mail(
730
                template_name='invitacion',
731 732 733
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
734 735 736 737
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
738 739 740 741 742
                    'programa_proyecto': f'{proyecto.programa.nombre_corto} '
                    + f'({proyecto.programa.nombre_largo})',
                    'descripcion_proyecto': pypandoc.convert_text(
                        proyecto.descripcion, 'md', format='html'
                    ).replace('\\\n', '  \n'),
743
                    'site_url': settings.SITE_URL,
744 745 746
                },
            )

747
    def _enviar_solicitudes_visto_bueno_centro(self, request, proyecto):
748
        """Envia un mensaje al responsable del centro solicitando su visto bueno."""
749 750 751 752
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
753 754 755 756 757
                request,
                _(
                    'La dirección de correo electrónico del director o decano '
                    'del centro no es válida.'
                ),
758 759 760
            )
            return

761
        send_templated_mail(
762
            template_name='solicitud_visto_bueno_centro',
763 764 765
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
766 767 768 769
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
770 771 772 773 774
                'programa_proyecto': f'{proyecto.programa.nombre_corto} '
                f'({proyecto.programa.nombre_largo})',
                'descripcion_proyecto': pypandoc.convert_text(
                    proyecto.descripcion, 'md', format='html'
                ).replace('\\\n', '\n'),
775
                'site_url': settings.SITE_URL,
776 777
            },
        )
778

779
    def _is_email_valid(self, email):
780
        """Validate email address"""
781 782 783 784 785 786 787
        try:
            validate_email(email)
        except ValidationError:
            return False
        return True

    def _enviar_solicitudes_visto_bueno_estudio(self, request, proyecto):
788
        """Envia mensaje a los coordinadores del plan solicitando su visto bueno."""
789
        email_coordinadores_estudio = [
790
            f'{p.email_coordinador}'
791 792 793 794 795
            for p in proyecto.estudio.planes.all()
            if self._is_email_valid(p.email_coordinador)
        ]

        send_templated_mail(
796
            template_name='solicitud_visto_bueno_estudio',
797 798 799
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
800 801
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
802 803 804 805 806
                'programa_proyecto': f'{proyecto.programa.nombre_corto} '
                f'({proyecto.programa.nombre_largo})',
                'descripcion_proyecto': pypandoc.convert_text(
                    proyecto.descripcion, 'md', format='html'
                ).replace('\\\n', '\n'),
807
                'site_url': settings.SITE_URL,
808 809 810
            },
        )

811 812
    def test_func(self):
        # TODO: Comprobar fecha
813
        return self.es_coordinador(self.kwargs['pk'])
814

815 816

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

819 820
    # TODO: Comprobar estado/fecha
    model = Proyecto
821
    template_name = 'proyecto/update.html'
822 823

    def get_form_class(self, **kwargs):
824 825 826 827
        campo = self.kwargs['campo']
        if campo in ('centro', 'codigo', 'convocatoria', 'estado', 'estudio', 'linea', 'programa'):
            raise Http404(_('No puede editar ese campo.'))

828 829 830 831 832 833 834 835 836 837 838
        if campo not in (
            'titulo',
            'departamento',
            'licencia',
            'ayuda',
            'visto_bueno_centro',
            'visto_bueno_estudio',
        ):
            formulario = modelform_factory(
                Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()}
            )
839 840

            def as_p(self):
841
                """
842 843
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
844
                """
845
                return self._html_output(
846
                    normal_row='''<p%(html_class_attr)s>
847 848 849
                    %(label)s
                    %(help_text)s
                    %(field)s
850 851 852
                    </p>''',
                    error_row='%s',
                    row_ender='</p>',
853 854 855 856 857
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
858 859 860 861 862 863 864 865

            def clean(self):
                cleaned_data = super(formulario, self).clean()
                texto = cleaned_data.get(campo)
                # See <https://bleach.readthedocs.io/en/latest/clean.html>
                cleaned_data[campo] = mark_safe(
                    bleach.clean(
                        texto,
866 867 868
                        tags=(
                            bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')
                        ),
869 870 871
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
872 873 874 875 876 877 878
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

879
            return formulario
880 881
        self.fields = (campo,)
        return super().get_form_class()
882

883
    def test_func(self):
884
        """Devuelve si el usuario está autorizado a modificar este campo."""
885
        return (
886
            self.es_coordinador(self.kwargs['pk'])
887 888 889 890 891 892 893 894
            or (
                self.kwargs['campo'] == 'visto_bueno_centro'
                and self.es_decano_o_director(self.kwargs['pk'])
            )
            or (
                self.kwargs['campo'] == 'visto_bueno_estudio'
                and self.es_coordinador_estudio(self.kwargs['pk'])
            )
895
            or self.request.user.has_perm('indo.editar_proyecto')
896
        )
897

898

899
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
900
    """Lista los proyectos a los que está vinculado el usuario actual."""
901

902
    template_name = 'proyecto/mis-proyectos.html'
903 904

    def get_context_data(self, **kwargs):
905
        usuario = self.request.user
Enrique Matías Sánchez (Quique)'s avatar