views.py 35.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 25
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
26
from django.forms.models import modelform_factory
27
from django.http import Http404, HttpResponse
28
from django.shortcuts import get_object_or_404, redirect
29
from django.urls import reverse, reverse_lazy
30
from django.utils.safestring import mark_safe
31
from django.utils.translation import gettext_lazy as _
32
from django.views import View
33
from django.views.generic import DetailView, RedirectView, TemplateView
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
34
from django.views.generic.edit import CreateView, DeleteView, UpdateView
35

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

57

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

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

        return usuario_actual in usuarios_coordinadores

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

82
        return True if pp else False
83 84

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

93 94 95
        return True if pp else False

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

        return True if pp else False
107 108

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

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

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

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

130
    def esta_vinculado_o_es_decano_o_es_coordinador(self, proyecto_id):
131
        """
132
        Devuelve si el usuario actual está vinculado al proyecto indicado
133
        o es decano o director del centro del proyecto
134
        o es coordinador del plan de estudios del proyecto."""
135 136 137 138 139
        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.'
        )
140 141 142 143
        usuario_actual = self.request.user
        esta_autorizado = (
            self.esta_vinculado(proyecto_id)
            or self.es_decano_o_director(proyecto_id)
144
            or self.es_coordinador_estudio(proyecto_id)
145
            or usuario_actual.has_perm('indo.ver_proyecto')  # Gestores y evaluadores
146
        )
147

148 149
        return esta_autorizado

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

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

166 167 168 169 170
    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
171

172
        return usuario_actual == proyecto.evaluador
173

174

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


179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
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


198 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
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()

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

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


241 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):
        # Obtenemos los NIPs de los usuarios con vinculación «Evaluador externo innovacion ACPUA».
296
        nip_evaluadores = [136_040, 327_618, 329_639, 370_109]  # FIXME - WS G.I.
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318
        # 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})


319
class HomePageView(TemplateView):
320
    template_name = 'home.html'
321 322


323
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
324
    """Muestra un formulario para invitar a una persona a un proyecto determinado."""
325 326 327

    form_class = InvitacionForm
    model = ParticipanteProyecto
328
    template_name = 'participante-proyecto/invitar.html'
329 330 331

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
332 333
        proyecto_id = self.kwargs['proyecto_id']
        context['proyecto'] = Proyecto.objects.get(id=proyecto_id)
334 335 336 337 338
        return context

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs()
        # Update the kwargs for the form init method with ours
339
        kwargs.update(self.kwargs)  # self.kwargs contains all URL conf params
340
        kwargs['request'] = self.request
341 342 343
        return kwargs

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

346 347
    def test_func(self):
        # TODO: Comprobar estado del proyecto, fecha.
348 349 350
        return self.es_coordinador(self.kwargs['proyecto_id']) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
351

352

353
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
354
    """Aceptar la invitación a participar en un proyecto."""
355 356

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

    def post(self, request, *args, **kwargs):
360
        usuario_actual = self.request.user
361
        proyecto_id = kwargs.get('proyecto_id')
362
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
363

364
        num_equipos = usuario_actual.get_num_equipos(proyecto.convocatoria_id)
365
        num_max_equipos = proyecto.convocatoria.num_max_equipos
366 367 368 369
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
370
                    f'''No puede aceptar esta invitación porque ya forma parte del número
371 372
                máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder aceptar esta invitación, antes debería renunciar a participar
373
                en algún otro proyecto.'''
374 375 376 377
                ),
            )
            return super().post(request, *args, **kwargs)

378
        pp = get_object_or_404(
379 380 381 382
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
383
        )
384
        pp.tipo_participacion_id = 'participante'
385 386
        pp.save()

387 388 389
        messages.success(
            request, _(f'Ha pasado a ser participante del proyecto «{proyecto.titulo}».')
        )
390 391 392 393
        return super().post(request, *args, **kwargs)


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

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

    def post(self, request, *args, **kwargs):
400
        proyecto_id = request.POST.get('proyecto_id')
401 402 403
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
404 405 406 407
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
408
        )
409
        pp.tipo_participacion_id = 'invitacion_rehusada'
410 411
        pp.save()

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


418
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
419
    """Renunciar a participar en un proyecto."""
420 421

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

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

437 438 439
        messages.success(
            request, _(f'Ha renunciado a participar en el proyecto «{proyecto.titulo}».')
        )
440 441 442
        return super().post(request, *args, **kwargs)


443
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
444
    """Borra un registro de ParticipanteProyecto"""
445 446

    model = ParticipanteProyecto
447
    template_name = 'participante-proyecto/confirm_delete.html'
448 449

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

452
    def test_func(self):
453 454 455
        return self.es_coordinador(self.get_object().proyecto.id) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
456

457

458
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
459
    """Crea una nueva solicitud de proyecto"""
460

461
    model = Proyecto
462
    template_name = 'proyecto/new.html'
463
    form_class = ProyectoForm
464 465

    def form_valid(self, form):
466 467
        # This method is called when valid form data has been POSTed,
        # to do custom logic on form data. It should return an HttpResponse.
468 469 470
        proyecto = form.save()
        self._guardar_coordinador(proyecto)
        self._registrar_creacion(proyecto)
471
        return redirect('proyecto_detail', proyecto.id)
472

473
    def get_form(self, form_class=None):
474
        """
475
        Devuelve el formulario añadiendo automáticamente el campo Convocatoria,
476
        que es requerido, y el usuario, para comprobar si tiene los permisos necesarios.
477
        """
478
        form = super(ProyectoCreateView, self).get_form(form_class)
479
        form.instance.user = self.request.user
480 481 482
        form.instance.convocatoria = Convocatoria(date.today().year)
        return form

483
    def _guardar_coordinador(self, proyecto):
484
        pp = ParticipanteProyecto(
485 486 487
            proyecto=proyecto,
            tipo_participacion=TipoParticipacion(nombre='coordinador'),
            usuario=self.request.user,
488
        )
489
        pp.save()
490 491

    def _registrar_creacion(self, proyecto):
492
        evento = Evento.objects.get(nombre='creacion_solicitud')
493 494 495
        registro = Registro(
            descripcion='Creación inicial de la solicitud', evento=evento, proyecto=proyecto
        )
496 497
        registro.save()

498 499 500 501 502
    def test_func(self):
        # TODO: Comprobar usuario para Proyectos de titulación y POU.
        # TODO: Comprobar fecha
        return self.es_pas_o_pdi()

503

504
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
505
    """Cambia el estado de una solicitud de proyecto a Anulada."""
506 507 508 509

    model = Proyecto

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

    def post(self, request, *args, **kwargs):
513 514
        proyecto = Proyecto.objects.get(pk=kwargs.get('pk'))
        proyecto.estado = 'ANULADO'
515 516
        proyecto.save()

517
        messages.success(request, _('Su solicitud de proyecto ha sido anulada.'))
518 519 520
        return super().post(request, *args, **kwargs)

    def test_func(self):
521
        return self.es_coordinador(self.kwargs['pk'])
522 523


524
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
525
    """Muestra una solicitud de proyecto."""
526

527
    model = Proyecto
528
    template_name = 'proyecto/detail.html'
529 530 531 532

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

533 534
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
535

536 537
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
538

539
        participantes = (
540 541
            self.object.participantes.filter(tipo_participacion='participante')
            .order_by('usuario__first_name', 'usuario__last_name')
542 543
            .all()
        )
544
        context['participantes'] = participantes
545 546

        invitados = (
547 548 549
            self.object.participantes.filter(
                tipo_participacion__in=['invitado', 'invitacion_rehusada']
            )
550
            .order_by('tipo_participacion', 'usuario__first_name', 'usuario__last_name')
551 552
            .all()
        )
553
        context['invitados'] = invitados
554

555
        context['campos'] = json.loads(self.object.programa.campos)
556

557
        context['permitir_edicion'] = (
558
            self.es_coordinador(self.object.id) and self.object.en_borrador()
559
        ) or self.request.user.has_perm('indo.editar_proyecto')
560

561 562 563
        context['es_coordinador'] = (
            self.es_coordinador(self.object.id) and self.object.en_borrador()
        )
564

565
        return context
566

567
    def test_func(self):
568
        proyecto_id = self.kwargs['pk']
569
        return self.esta_vinculado_o_es_decano_o_es_coordinador(proyecto_id)
570 571


572 573 574 575 576 577 578 579 580 581 582 583 584 585 586
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


587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607
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')
        )


608 609
class ProyectoTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""
610

611 612
    permission_required = 'indo.listar_proyectos'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
613
    table_class = ProyectosTable
614
    template_name = 'gestion/proyecto/tabla_proyectos.html'
615 616 617

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
618
        context['anyo'] = self.kwargs['anyo']
619 620 621 622
        return context

    def get_queryset(self):
        return (
623 624 625
            Proyecto.objects.filter(convocatoria__id=self.kwargs['anyo'])
            .exclude(estado__in=['BORRADOR', 'ANULADO'])
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
626 627 628
        )


629
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
630
    """Presenta una solicitud de proyecto.
631

632
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
633
    Se envían correos a los agentes involucrados.
634
    """
635

636
    def get_redirect_url(self, *args, **kwargs):
637
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
638 639

    def post(self, request, *args, **kwargs):
640
        proyecto_id = kwargs.get('pk')
641 642 643
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
644

645
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
646
        num_max_equipos = proyecto.convocatoria.num_max_equipos
647 648 649 650
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
651
                    f'''No puede presentar esta solicitud porque ya forma parte
652 653 654
                    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.'''
655 656 657 658
                ),
            )
            return super().post(request, *args, **kwargs)

659
        if request.user.get_colectivo_principal() == 'ADS' and proyecto.ayuda != 0:
660 661
            messages.error(
                request,
662 663 664 665
                _(
                    'Los profesores de los centros adscritos no pueden coordinar '
                    'proyectos con financiación.'
                ),
666
            )
667 668
            return super().post(request, *args, **kwargs)

669
        if proyecto.ayuda > proyecto.programa.max_ayuda:
670 671 672
            messages.error(
                request,
                _(
673 674
                    f'La ayuda solicitada ({proyecto.ayuda} €) excede el máximo '
                    f'permitido para este programa ({proyecto.programa.max_ayuda} €).'
675 676 677 678 679
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
680 681 682
            messages.error(
                request, _('La solicitud debe incluir al menos un invitado a participar.')
            )
683
            return super().post(request, *args, **kwargs)
684

685
        self._enviar_invitaciones(request, proyecto)
686 687 688 689 690 691 692

        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)

693
        # TODO Enviar "resguardo" al solicitante. PDF?
694

695
        proyecto.estado = 'SOLICITADO'
696 697 698
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
703
        """Envia un mensaje a cada uno de los invitados al proyecto."""
704
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
705
            send_templated_mail(
706
                template_name='invitacion',
707 708 709
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
710 711 712 713
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
714 715 716 717 718
                    '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'),
719
                    'site_url': settings.SITE_URL,
720 721 722
                },
            )

723
    def _enviar_solicitudes_visto_bueno_centro(self, request, proyecto):
724
        """Envia un mensaje al responsable del centro solicitando su visto bueno."""
725 726 727 728
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
729 730 731 732 733
                request,
                _(
                    'La dirección de correo electrónico del director o decano '
                    'del centro no es válida.'
                ),
734 735 736
            )
            return

737
        send_templated_mail(
738
            template_name='solicitud_visto_bueno_centro',
739 740 741
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
742 743 744 745
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
746 747 748 749 750
                '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'),
751
                'site_url': settings.SITE_URL,
752 753
            },
        )
754

755
    def _is_email_valid(self, email):
756
        """Validate email address"""
757 758 759 760 761 762 763
        try:
            validate_email(email)
        except ValidationError:
            return False
        return True

    def _enviar_solicitudes_visto_bueno_estudio(self, request, proyecto):
764
        """Envia mensaje a los coordinadores del plan solicitando su visto bueno."""
765
        email_coordinadores_estudio = [
766
            f'{p.email_coordinador}'
767 768 769 770 771
            for p in proyecto.estudio.planes.all()
            if self._is_email_valid(p.email_coordinador)
        ]

        send_templated_mail(
772
            template_name='solicitud_visto_bueno_estudio',
773 774 775
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
776 777
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
778 779 780 781 782
                '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'),
783
                'site_url': settings.SITE_URL,
784 785 786
            },
        )

787 788
    def test_func(self):
        # TODO: Comprobar fecha
789
        return self.es_coordinador(self.kwargs['pk'])
790

791 792

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

795 796
    # TODO: Comprobar estado/fecha
    model = Proyecto
797
    template_name = 'proyecto/update.html'
798 799

    def get_form_class(self, **kwargs):
800 801 802 803
        campo = self.kwargs['campo']
        if campo in ('centro', 'codigo', 'convocatoria', 'estado', 'estudio', 'linea', 'programa'):
            raise Http404(_('No puede editar ese campo.'))

804 805 806 807 808 809 810 811 812 813 814
        if campo not in (
            'titulo',
            'departamento',
            'licencia',
            'ayuda',
            'visto_bueno_centro',
            'visto_bueno_estudio',
        ):
            formulario = modelform_factory(
                Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()}
            )
815 816

            def as_p(self):
817
                """
818 819
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
820
                """
821
                return self._html_output(
822
                    normal_row='''<p%(html_class_attr)s>
823 824 825
                    %(label)s
                    %(help_text)s
                    %(field)s
826 827 828
                    </p>''',
                    error_row='%s',
                    row_ender='</p>',
829 830 831 832 833
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
834 835 836 837 838 839 840 841

            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,
842 843 844
                        tags=(
                            bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')
                        ),
845 846 847
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
848 849 850 851 852 853 854
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

855
            return formulario
856 857
        self.fields = (campo,)
        return super().get_form_class()
858

859
    def test_func(self):
860
        """Devuelve si el usuario está autorizado a modificar este campo."""
861
        return (
862
            self.es_coordinador(self.kwargs['pk'])
863 864 865 866 867 868 869 870
            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'])
            )
871
            or self.request.user.has_perm('indo.editar_proyecto')
872
        )
873

874

875
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
876
    """Lista los proyectos a los que está vinculado el usuario actual."""
877

878
    template_name = 'proyecto/mis-proyectos.html'
879 880

    def get_context_data(self, **kwargs):
881
        usuario = self.request.user
882
        anyo = self.kwargs['anyo']
883
        context = super().get_context_data(**kwargs)
884
        context['proyectos_coordinados'] = (
885
            Proyecto.objects.filter(
886
                convocatoria__id=anyo,
887
                participantes__usuario=usuario,
888
                participantes__tipo_participacion_id__in=['coordinador', 'coordinador_2'],
889
            )
890 891
            .exclude(estado='ANULADO')
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')