views.py 36.3 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 296
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):
        # Obtenemos los NIPs de los usuarios con vinculación «Evaluador externo innovacion ACPUA».
297
        nip_evaluadores = [136_040, 327_618, 329_639, 370_109]  # FIXME - WS G.I.
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319
        # Creamos los usuarios que no existan ya en la aplicación.
        User = get_user_model()
        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():
            nip_evaluadores = [str(nip) for nip in nip_evaluadores]
            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})


320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
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])


341
class HomePageView(TemplateView):
342
    template_name = 'home.html'
343 344


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

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

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

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

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

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

374

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

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

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

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

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

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


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

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

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

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


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

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

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

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


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

    model = ParticipanteProyecto
469
    template_name = 'participante-proyecto/confirm_delete.html'
470 471

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

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

479

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

483
    model = Proyecto
484
    template_name = 'proyecto/new.html'
485
    form_class = ProyectoForm
486 487

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

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

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

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

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

525

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

    model = Proyecto

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

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

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

    def test_func(self):
543
        return self.es_coordinador(self.kwargs['pk'])
544 545


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

549
    model = Proyecto
550
    template_name = 'proyecto/detail.html'
551 552 553 554

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

555 556
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
557

558 559
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
560

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

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

577
        context['campos'] = json.loads(self.object.programa.campos)
578

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

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

587
        return context
588

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


594 595 596 597 598 599 600 601 602 603 604 605 606 607 608
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


609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629
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')
        )


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

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

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

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


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

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

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

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

        # TODO ¿Chequear el estado actual del proyecto?
666

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

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

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

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

707
        self._enviar_invitaciones(request, proyecto)
708 709 710 711 712 713 714

        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)

715
        # TODO Enviar "resguardo" al solicitante. PDF?
716

717
        proyecto.estado = 'SOLICITADO'
718 719 720
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
725
        """Envia un mensaje a cada uno de los invitados al proyecto."""
726
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
727
            send_templated_mail(
728
                template_name='invitacion',
729 730 731
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
732 733 734 735
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
736 737 738 739 740
                    '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'),
741
                    'site_url': settings.SITE_URL,
742 743 744
                },
            )

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

759
        send_templated_mail(
760
            template_name='solicitud_visto_bueno_centro',
761 762 763
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
764 765 766 767
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
768 769 770 771 772
                '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'),
773
                'site_url': settings.SITE_URL,
774 775
            },
        )
776

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

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

        send_templated_mail(
794
            template_name='solicitud_visto_bueno_estudio',
795 796 797
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
798 799
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
800 801 802 803 804
                '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'),
805
                'site_url': settings.SITE_URL,
806 807 808
            },
        )

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

813 814

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

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

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

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

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

            formulario.as_p = as_p
856 857 858 859 860 861 862 863

            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,
864 865 866
                        tags=(
                            bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')
                        ),
867 868 869
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
870 871 872 873 874 875 876
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

877
            return formulario
878 879
        self.fields = (campo,)
        return super().get_form_class()
880

881
    def test_func(self):
882
        """Devuelve si el usuario está autorizado a modificar este campo."""
883
        return (
884
            self.es_coordinador(self.kwargs['pk'])
885 886 887 888 889 890 891 892
            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'])
            )
893
            or self.request.user.has_perm('indo.editar_proyecto')
894
        )
895

896

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

900
    template_name = 'proyecto/mis-proyectos.html'
901 902

    def get_context_data(self, **kwargs):
903
        usuario = self.request.user
904
        anyo = self.kwargs['anyo']
905
        context = super().