views.py 35.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 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 232 233 234 235 236 237
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()

        return redirect('evaluacion', proyecto.id)

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


238 239 240 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
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».
293
        nip_evaluadores = [136_040, 327_618, 329_639, 370_109]  # FIXME - WS G.I.
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315
        # 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})


316
class HomePageView(TemplateView):
317
    template_name = 'home.html'
318 319


320
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
321
    """Muestra un formulario para invitar a una persona a un proyecto determinado."""
322 323 324

    form_class = InvitacionForm
    model = ParticipanteProyecto
325
    template_name = 'participante-proyecto/invitar.html'
326 327 328

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

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

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

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

349

350
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
351
    """Aceptar la invitación a participar en un proyecto."""
352 353

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

    def post(self, request, *args, **kwargs):
357
        usuario_actual = self.request.user
358
        proyecto_id = kwargs.get('proyecto_id')
359
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
360

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

375
        pp = get_object_or_404(
376 377 378 379
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
380
        )
381
        pp.tipo_participacion_id = 'participante'
382 383
        pp.save()

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


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

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

    def post(self, request, *args, **kwargs):
397
        proyecto_id = request.POST.get('proyecto_id')
398 399 400
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        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 = 'invitacion_rehusada'
407 408
        pp.save()

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


415
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
416
    """Renunciar 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='participante',
430
        )
431
        pp.tipo_participacion_id = 'invitacion_rehusada'
432 433
        pp.save()

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


440
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
441
    """Borra un registro de ParticipanteProyecto"""
442 443

    model = ParticipanteProyecto
444
    template_name = 'participante-proyecto/confirm_delete.html'
445 446

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

449
    def test_func(self):
450 451 452
        return self.es_coordinador(self.get_object().proyecto.id) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
453

454

455
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
456
    """Crea una nueva solicitud de proyecto"""
457

458
    model = Proyecto
459
    template_name = 'proyecto/new.html'
460
    form_class = ProyectoForm
461 462

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

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

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

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

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

500

501
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
502
    """Cambia el estado de una solicitud de proyecto a Anulada."""
503 504 505 506

    model = Proyecto

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

    def post(self, request, *args, **kwargs):
510 511
        proyecto = Proyecto.objects.get(pk=kwargs.get('pk'))
        proyecto.estado = 'ANULADO'
512 513
        proyecto.save()

514
        messages.success(request, _('Su solicitud de proyecto ha sido anulada.'))
515 516 517
        return super().post(request, *args, **kwargs)

    def test_func(self):
518
        return self.es_coordinador(self.kwargs['pk'])
519 520


521
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
522
    """Muestra una solicitud de proyecto."""
523

524
    model = Proyecto
525
    template_name = 'proyecto/detail.html'
526 527 528 529

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

530 531
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
532

533 534
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
535

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

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

552
        context['campos'] = json.loads(self.object.programa.campos)
553

554
        context['permitir_edicion'] = (
555
            self.es_coordinador(self.object.id) and self.object.en_borrador()
556
        ) or self.request.user.has_perm('indo.editar_proyecto')
557

558 559 560
        context['es_coordinador'] = (
            self.es_coordinador(self.object.id) and self.object.en_borrador()
        )
561

562
        return context
563

564
    def test_func(self):
565
        proyecto_id = self.kwargs['pk']
566
        return self.esta_vinculado_o_es_decano_o_es_coordinador(proyecto_id)
567 568


569 570 571 572 573 574 575 576 577 578 579 580 581 582 583
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


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


605 606
class ProyectoTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""
607

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

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
615
        context['anyo'] = self.kwargs['anyo']
616 617 618 619
        return context

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


626
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
627
    """Presenta una solicitud de proyecto.
628

629
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
630
    Se envían correos a los agentes involucrados.
631
    """
632

633
    def get_redirect_url(self, *args, **kwargs):
634
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
635 636

    def post(self, request, *args, **kwargs):
637
        proyecto_id = kwargs.get('pk')
638 639 640
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
641

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

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

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

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

682
        self._enviar_invitaciones(request, proyecto)
683 684 685 686 687 688 689

        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)

690
        # TODO Enviar "resguardo" al solicitante. PDF?
691

692
        proyecto.estado = 'SOLICITADO'
693 694 695
        proyecto.save()

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

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

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

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

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

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

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

784 785
    def test_func(self):
        # TODO: Comprobar fecha
786
        return self.es_coordinador(self.kwargs['pk'])
787

788 789

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

792 793
    # TODO: Comprobar estado/fecha
    model = Proyecto
794
    template_name = 'proyecto/update.html'
795 796

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

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

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

            formulario.as_p = as_p
831 832 833 834 835 836 837 838

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

            formulario.clean = clean

852
            return formulario
853 854
        self.fields = (campo,)
        return super().get_form_class()
855

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

871

872
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
873
    """Lista los proyectos a los que está vinculado el usuario actual."""
874

875
    template_name = 'proyecto/mis-proyectos.html'
876 877

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