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

5 6 7 8 9
# 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
10
import bleach
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
11
import pypandoc
12

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

34 35 36 37 38 39 40 41 42 43 44 45 46
# Local Django
from .forms import EvaluadorForm, InvitacionForm, ProyectoForm
from .models import (
    Centro,
    Convocatoria,
    Evento,
    ParticipanteProyecto,
    Plan,
    Proyecto,
    Registro,
    TipoParticipacion,
)
from .tables import EvaluadoresTable, ProyectosEvaluadosTable, ProyectosTable
47

48

49
class ChecksMixin(UserPassesTestMixin):
50
    """Proporciona comprobaciones para autorizar o no una acción a un usuario."""
51 52

    def es_coordinador(self, proyecto_id):
53
        """Devuelve si el usuario actual es coordinador del proyecto indicado."""
54
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
55 56
        usuario_actual = self.request.user
        coordinadores_participantes = proyecto.participantes.filter(
57
            tipo_participacion__in=['coordinador', 'coordinador_2']
58
        ).all()
59 60
        usuarios_coordinadores = list(map(lambda p: p.usuario, coordinadores_participantes))
        self.permission_denied_message = _('Usted no es coordinador de este proyecto.')
61 62 63 64

        return usuario_actual in usuarios_coordinadores

    def es_participante(self, proyecto_id):
65
        """Devuelve si el usuario actual es participante del proyecto indicado."""
66
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
67
        usuario_actual = self.request.user
68 69 70
        pp = proyecto.participantes.filter(
            usuario=usuario_actual, tipo_participacion='participante'
        ).all()
71
        self.permission_denied_message = _('Usted no es participante de este proyecto.')
72

73
        return True if pp else False
74 75

    def es_invitado(self, proyecto_id):
76
        """Devuelve si el usuario actual es invitado del proyecto indicado."""
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='invitado'
        ).all()
82
        self.permission_denied_message = _('Usted no está invitado a este proyecto.')
83

84 85 86
        return True if pp else False

    def esta_vinculado(self, proyecto_id):
87
        """Devuelve si el usuario actual está vinculado al proyecto indicado."""
88
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
89
        usuario_actual = self.request.user
90 91
        pp = (
            proyecto.participantes.filter(usuario=usuario_actual)
92
            .exclude(tipo_participacion='invitacion_rehusada')
93 94
            .all()
        )
95
        self.permission_denied_message = _('Usted no está vinculado a este proyecto.')
96 97

        return True if pp else False
98 99

    def es_pas_o_pdi(self):
100
        """Devuelve si el usuario actual es PAS o PDI de la UZ o de sus centros adscritos."""
101 102
        usuario_actual = self.request.user
        colectivos_del_usuario = json.loads(usuario_actual.colectivos)
103
        self.permission_denied_message = _('Usted no es PAS ni PDI.')
104

105 106 107
        return any(
            col_autorizado in colectivos_del_usuario for col_autorizado in ['PAS', 'ADS', 'PDI']
        )
108

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

        return usuario_actual.username == str(nip_decano)

121
    def esta_vinculado_o_es_decano_o_es_coordinador(self, proyecto_id):
122
        """
123
        Devuelve si el usuario actual está vinculado al proyecto indicado
124
        o es decano o director del centro del proyecto
125
        o es coordinador del plan de estudios del proyecto."""
126 127 128 129
        usuario_actual = self.request.user
        esta_autorizado = (
            self.esta_vinculado(proyecto_id)
            or self.es_decano_o_director(proyecto_id)
130
            or self.es_coordinador_estudio(proyecto_id)
131
            or usuario_actual.has_perm('indo.ver_proyecto')  # Gestores y evaluadores
132 133
        )
        self.permission_denied_message = _(
134 135 136
            'Usted no está vinculado a este proyecto, '
            'ni es decano/director del centro del proyecto, '
            'ni es coordinador del plan de estudios del proyecto.'
137 138 139
        )
        return esta_autorizado

140
    def es_coordinador_estudio(self, proyecto_id):
141
        """Devuelve si el usuario actual es coordinador del estudio del proyecto."""
142 143 144 145 146
        usuario_actual = self.request.user
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        estudio = proyecto.estudio
        if not estudio:
            return False
147 148 149
        nip_coordinadores = [
            f'{p.nip_coordinador}' for p in estudio.planes.all() if p.nip_coordinador
        ]
150

151 152 153
        self.permission_denied_message = _(
            'Usted no es coordinador del plan de estudios del proyecto.'
        )
154 155 156

        return usuario_actual.username in nip_coordinadores

157

158
class AyudaView(TemplateView):
159
    template_name = 'ayuda.html'
160 161


162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 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 238 239
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».
        nip_evaluadores = [136040, 327618, 329639, 370109]  # FIXME - WS G.I.
        # 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})


240
class HomePageView(TemplateView):
241
    template_name = 'home.html'
242 243


244
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
245
    """Muestra un formulario para invitar a una persona a un proyecto determinado."""
246 247 248

    form_class = InvitacionForm
    model = ParticipanteProyecto
249
    template_name = 'participante-proyecto/invitar.html'
250 251 252

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
253 254
        proyecto_id = self.kwargs['proyecto_id']
        context['proyecto'] = Proyecto.objects.get(id=proyecto_id)
255 256 257 258 259
        return context

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs()
        # Update the kwargs for the form init method with ours
260
        kwargs.update(self.kwargs)  # self.kwargs contains all URL conf params
261
        kwargs['request'] = self.request
262 263 264
        return kwargs

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

267 268
    def test_func(self):
        # TODO: Comprobar estado del proyecto, fecha.
269 270 271
        return self.es_coordinador(self.kwargs['proyecto_id']) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
272

273

274
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
275
    """Aceptar la invitación a participar en un proyecto."""
276 277

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

    def post(self, request, *args, **kwargs):
281
        usuario_actual = self.request.user
282
        proyecto_id = kwargs.get('proyecto_id')
283
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
284

285
        num_equipos = usuario_actual.get_num_equipos(proyecto.convocatoria_id)
286
        num_max_equipos = proyecto.convocatoria.num_max_equipos
287 288 289 290
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
291
                    f'''No puede aceptar esta invitación porque ya forma parte del número
292 293
                máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder aceptar esta invitación, antes debería renunciar a participar
294
                en algún otro proyecto.'''
295 296 297 298
                ),
            )
            return super().post(request, *args, **kwargs)

299
        pp = get_object_or_404(
300 301 302 303
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
304
        )
305
        pp.tipo_participacion_id = 'participante'
306 307
        pp.save()

308 309 310
        messages.success(
            request, _(f'Ha pasado a ser participante del proyecto «{proyecto.titulo}».')
        )
311 312 313 314
        return super().post(request, *args, **kwargs)


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

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

    def post(self, request, *args, **kwargs):
321
        proyecto_id = request.POST.get('proyecto_id')
322 323 324
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
325 326 327 328
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
329
        )
330
        pp.tipo_participacion_id = 'invitacion_rehusada'
331 332
        pp.save()

333 334 335
        messages.success(
            request, _(f'Ha rehusado ser participante del proyecto «{proyecto.titulo}».')
        )
336 337 338
        return super().post(request, *args, **kwargs)


339
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
340
    """Renunciar a participar en un proyecto."""
341 342

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

    def post(self, request, *args, **kwargs):
346
        proyecto_id = request.POST.get('proyecto_id')
347 348 349
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
350 351 352 353
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='participante',
354
        )
355
        pp.tipo_participacion_id = 'invitacion_rehusada'
356 357
        pp.save()

358 359 360
        messages.success(
            request, _(f'Ha renunciado a participar en el proyecto «{proyecto.titulo}».')
        )
361 362 363
        return super().post(request, *args, **kwargs)


364
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
365
    """Borra un registro de ParticipanteProyecto"""
366 367

    model = ParticipanteProyecto
368
    template_name = 'participante-proyecto/confirm_delete.html'
369 370

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

373
    def test_func(self):
374 375 376
        return self.es_coordinador(self.get_object().proyecto.id) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
377

378

379
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
380
    """Crea una nueva solicitud de proyecto"""
381

382
    model = Proyecto
383
    template_name = 'proyecto/new.html'
384
    form_class = ProyectoForm
385 386

    def form_valid(self, form):
387 388
        # This method is called when valid form data has been POSTed,
        # to do custom logic on form data. It should return an HttpResponse.
389 390 391
        proyecto = form.save()
        self._guardar_coordinador(proyecto)
        self._registrar_creacion(proyecto)
392
        return redirect('proyecto_detail', proyecto.id)
393

394
    def get_form(self, form_class=None):
395
        """
396
        Devuelve el formulario añadiendo automáticamente el campo Convocatoria,
397
        que es requerido, y el usuario, para comprobar si tiene los permisos necesarios.
398
        """
399
        form = super(ProyectoCreateView, self).get_form(form_class)
400
        form.instance.user = self.request.user
401 402 403
        form.instance.convocatoria = Convocatoria(date.today().year)
        return form

404
    def _guardar_coordinador(self, proyecto):
405
        pp = ParticipanteProyecto(
406 407 408
            proyecto=proyecto,
            tipo_participacion=TipoParticipacion(nombre='coordinador'),
            usuario=self.request.user,
409
        )
410
        pp.save()
411 412

    def _registrar_creacion(self, proyecto):
413
        evento = Evento.objects.get(nombre='creacion_solicitud')
414 415 416
        registro = Registro(
            descripcion='Creación inicial de la solicitud', evento=evento, proyecto=proyecto
        )
417 418
        registro.save()

419 420 421 422 423
    def test_func(self):
        # TODO: Comprobar usuario para Proyectos de titulación y POU.
        # TODO: Comprobar fecha
        return self.es_pas_o_pdi()

424

425
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
426
    """Cambia el estado de una solicitud de proyecto a Anulada."""
427 428 429 430

    model = Proyecto

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

    def post(self, request, *args, **kwargs):
434 435
        proyecto = Proyecto.objects.get(pk=kwargs.get('pk'))
        proyecto.estado = 'ANULADO'
436 437
        proyecto.save()

438
        messages.success(request, _('Su solicitud de proyecto ha sido anulada.'))
439 440 441
        return super().post(request, *args, **kwargs)

    def test_func(self):
442
        return self.es_coordinador(self.kwargs['pk'])
443 444


445
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
446
    """Muestra una solicitud de proyecto."""
447

448
    model = Proyecto
449
    template_name = 'proyecto/detail.html'
450 451 452 453

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

454 455
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
456

457 458
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
459

460
        participantes = (
461 462
            self.object.participantes.filter(tipo_participacion='participante')
            .order_by('usuario__first_name', 'usuario__last_name')
463 464
            .all()
        )
465
        context['participantes'] = participantes
466 467

        invitados = (
468 469 470
            self.object.participantes.filter(
                tipo_participacion__in=['invitado', 'invitacion_rehusada']
            )
471
            .order_by('tipo_participacion', 'usuario__first_name', 'usuario__last_name')
472 473
            .all()
        )
474
        context['invitados'] = invitados
475

476
        context['campos'] = json.loads(self.object.programa.campos)
477

478
        context['permitir_edicion'] = (
479
            self.es_coordinador(self.object.id) and self.object.en_borrador()
480
        ) or self.request.user.has_perm('indo.editar_proyecto')
481

482 483 484
        context['es_coordinador'] = (
            self.es_coordinador(self.object.id) and self.object.en_borrador()
        )
485

486
        return context
487

488
    def test_func(self):
489
        proyecto_id = self.kwargs['pk']
490
        return self.esta_vinculado_o_es_decano_o_es_coordinador(proyecto_id)
491 492


493 494
class ProyectoTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""
495

496 497
    permission_required = 'indo.listar_proyectos'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
498
    table_class = ProyectosTable
499
    template_name = 'gestion/proyecto/tabla_proyectos.html'
500 501 502

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
503
        context['anyo'] = self.kwargs['anyo']
504 505 506 507
        return context

    def get_queryset(self):
        return (
508 509 510
            Proyecto.objects.filter(convocatoria__id=self.kwargs['anyo'])
            .exclude(estado__in=['BORRADOR', 'ANULADO'])
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
511 512 513
        )


514
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
515
    """Presenta una solicitud de proyecto.
516

517
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
518
    Se envían correos a los agentes involucrados.
519
    """
520

521
    def get_redirect_url(self, *args, **kwargs):
522
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
523 524

    def post(self, request, *args, **kwargs):
525
        proyecto_id = kwargs.get('pk')
526 527 528
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
529

530
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
531
        num_max_equipos = proyecto.convocatoria.num_max_equipos
532 533 534 535
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
536
                    f'''No puede presentar esta solicitud porque ya forma parte
537 538
                del número máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder presentar esta solicitud de proyecto, antes debería renunciar
539
                a participar en algún otro proyecto.'''
540 541 542 543
                ),
            )
            return super().post(request, *args, **kwargs)

544
        if request.user.get_colectivo_principal() == 'ADS' and proyecto.ayuda != 0:
545 546
            messages.error(
                request,
547 548 549 550
                _(
                    'Los profesores de los centros adscritos no pueden coordinar '
                    'proyectos con financiación.'
                ),
551
            )
552 553
            return super().post(request, *args, **kwargs)

554
        if proyecto.ayuda > proyecto.programa.max_ayuda:
555 556 557
            messages.error(
                request,
                _(
558 559
                    f'La ayuda solicitada ({proyecto.ayuda} €) excede el máximo '
                    f'permitido para este programa ({proyecto.programa.max_ayuda} €).'
560 561 562 563 564
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
565 566 567
            messages.error(
                request, _('La solicitud debe incluir al menos un invitado a participar.')
            )
568
            return super().post(request, *args, **kwargs)
569

570
        self._enviar_invitaciones(request, proyecto)
571 572 573 574 575 576 577

        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)

578
        # TODO Enviar "resguardo" al solicitante. PDF?
579

580
        proyecto.estado = 'SOLICITADO'
581 582 583
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
588
        """Envia un mensaje a cada uno de los invitados al proyecto."""
589
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
590
            send_templated_mail(
591
                template_name='invitacion',
592 593 594
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
595 596 597 598
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
599 600 601 602 603
                    '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'),
604
                    'site_url': settings.SITE_URL,
605 606 607
                },
            )

608
    def _enviar_solicitudes_visto_bueno_centro(self, request, proyecto):
609
        """Envia un mensaje al responsable del centro solicitando su visto bueno."""
610 611 612 613
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
614 615 616 617 618
                request,
                _(
                    'La dirección de correo electrónico del director o decano '
                    'del centro no es válida.'
                ),
619 620 621
            )
            return

622
        send_templated_mail(
623
            template_name='solicitud_visto_bueno_centro',
624 625 626
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
627 628 629 630
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
631 632 633 634 635
                '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'),
636
                'site_url': settings.SITE_URL,
637 638
            },
        )
639

640
    def _is_email_valid(self, email):
641
        """Validate email address"""
642 643 644 645 646 647 648
        try:
            validate_email(email)
        except ValidationError:
            return False
        return True

    def _enviar_solicitudes_visto_bueno_estudio(self, request, proyecto):
649
        """Envia mensaje a los coordinadores del plan solicitando su visto bueno."""
650
        email_coordinadores_estudio = [
651
            f'{p.email_coordinador}'
652 653 654 655 656
            for p in proyecto.estudio.planes.all()
            if self._is_email_valid(p.email_coordinador)
        ]

        send_templated_mail(
657
            template_name='solicitud_visto_bueno_estudio',
658 659 660
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
661 662
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
663 664 665 666 667
                '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'),
668
                'site_url': settings.SITE_URL,
669 670 671
            },
        )

672 673
    def test_func(self):
        # TODO: Comprobar fecha
674
        return self.es_coordinador(self.kwargs['pk'])
675

676 677

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

680 681
    # TODO: Comprobar estado/fecha
    model = Proyecto
682
    template_name = 'proyecto/update.html'
683 684

    def get_form_class(self, **kwargs):
685 686 687 688
        campo = self.kwargs['campo']
        if campo in ('centro', 'codigo', 'convocatoria', 'estado', 'estudio', 'linea', 'programa'):
            raise Http404(_('No puede editar ese campo.'))

689 690 691 692 693 694 695 696 697 698 699
        if campo not in (
            'titulo',
            'departamento',
            'licencia',
            'ayuda',
            'visto_bueno_centro',
            'visto_bueno_estudio',
        ):
            formulario = modelform_factory(
                Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()}
            )
700 701

            def as_p(self):
702
                """
703 704
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
705
                """
706
                return self._html_output(
707
                    normal_row='''<p%(html_class_attr)s>
708 709 710
                    %(label)s
                    %(help_text)s
                    %(field)s
711 712 713
                    </p>''',
                    error_row='%s',
                    row_ender='</p>',
714 715 716 717 718
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
719 720 721 722 723 724 725 726

            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,
727 728 729
                        tags=(
                            bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')
                        ),
730 731 732
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
733 734 735 736 737 738 739
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

740
            return formulario
741 742
        self.fields = (campo,)
        return super().get_form_class()
743

744
    def test_func(self):
745
        """Devuelve si el usuario está autorizado a modificar este campo."""
746
        return (
747
            self.es_coordinador(self.kwargs['pk'])
748 749 750 751 752 753 754 755
            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'])
            )
756
            or self.request.user.has_perm('indo.editar_proyecto')
757
        )
758

759

760
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
761
    """Lista los proyectos a los que está vinculado el usuario actual."""
762

763
    template_name = 'proyecto/mis-proyectos.html'
764 765

    def get_context_data(self, **kwargs):
766
        usuario = self.request.user
767
        anyo = self.kwargs['anyo']
768
        context = super().get_context_data(**kwargs)
769
        context['proyectos_coordinados'] = (
770
            Proyecto.objects.filter(
771
                convocatoria__id=anyo,
772
                participantes__usuario=usuario,
773
                participantes__tipo_participacion_id__in=['coordinador', 'coordinador_2'],
774
            )
775 776
            .exclude(estado='ANULADO')
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
777 778
            .all()
        )
779
        context['proyectos_participados'] = (
780
            Proyecto.objects.filter(
781
                convocatoria__id=anyo,
782
                participantes__usuario=usuario,
783
                participantes__tipo_participacion_id='participante',
784
            )
785
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
786 787
            .all()
        )
788
        context['proyectos_invitado'] = (
789
            Proyecto.objects.filter(
790 791 792
                convocatoria__id=anyo,
                participantes__usuario=usuario,
                participantes__tipo_participacion_id='invitado',
793
            )
794
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
795 796
            .all()
        )
797

798
        try:
799
            nip_usuario = int(usuario.username)
800
        except ValueError:
801
            nip_usuario = 0
802

803
        centros_dirigidos = Centro.objects.filter(nip_decano=nip_usuario).all()
804
        if centros_dirigidos:
805
            context['proyectos_centros_dirigidos'] = Proyecto.objects.filter(
806 807 808
                convocatoria_id=anyo,
                programa__requiere_visto_bueno_centro=True,
                centro__in=centros_dirigidos,
809 810
            ).all()

811 812 813
        planes_coordinados = Plan.objects.filter(nip_coordinador=nip_usuario).all()
        if planes_coordinados:
            id_estudios_coordinados = set([p.estudio_id for p in planes_coordinados])
814
            context['proyectos_estudios_coordinados'] = Proyecto.objects.filter(
815 816 817 818 819
                convocatoria_id=anyo,
                programa__requiere_visto_bueno_estudio=True,
                estudio_id__in=id_estudios_coordinados,
            )

820
        return context