views.py 32.9 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
# Local Django
from .forms import EvaluadorForm, InvitacionForm, ProyectoForm
from .models import (
    Centro,
    Convocatoria,
39
    Criterio,
40 41 42 43 44 45
    Evento,
    ParticipanteProyecto,
    Plan,
    Proyecto,
    Registro,
    TipoParticipacion,
46
    Valoracion,
47 48
)
from .tables import EvaluadoresTable, ProyectosEvaluadosTable, ProyectosTable
49

50

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

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

        return usuario_actual in usuarios_coordinadores

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

75
        return True if pp else False
76 77

    def es_invitado(self, proyecto_id):
78
        """Devuelve si el usuario actual es invitado del proyecto indicado."""
79
        self.permission_denied_message = _('Usted no está invitado a este proyecto.')
80
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
81
        usuario_actual = self.request.user
82 83 84
        pp = proyecto.participantes.filter(
            usuario=usuario_actual, tipo_participacion='invitado'
        ).all()
85

86 87 88
        return True if pp else False

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

        return True if pp else False
100 101

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

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

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

120
        nip_decano = centro.nip_decano
121 122
        return usuario_actual.username == str(nip_decano)

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

141 142
        return esta_autorizado

143
    def es_coordinador_estudio(self, proyecto_id):
144
        """Devuelve si el usuario actual es coordinador del estudio del proyecto."""
145 146 147
        self.permission_denied_message = _(
            'Usted no es coordinador del plan de estudios del proyecto.'
        )
148
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
149
        usuario_actual = self.request.user
150 151 152
        estudio = proyecto.estudio
        if not estudio:
            return False
153

154 155 156
        nip_coordinadores = [
            f'{p.nip_coordinador}' for p in estudio.planes.all() if p.nip_coordinador
        ]
157
        return usuario_actual.username in nip_coordinadores
158

159 160 161 162 163
    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
164

165
        return usuario_actual == proyecto.evaluador
166

167

168
class AyudaView(TemplateView):
169
    template_name = 'ayuda.html'
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
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'])


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 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
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})


290
class HomePageView(TemplateView):
291
    template_name = 'home.html'
292 293


294
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
295
    """Muestra un formulario para invitar a una persona a un proyecto determinado."""
296 297 298

    form_class = InvitacionForm
    model = ParticipanteProyecto
299
    template_name = 'participante-proyecto/invitar.html'
300 301 302

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
303 304
        proyecto_id = self.kwargs['proyecto_id']
        context['proyecto'] = Proyecto.objects.get(id=proyecto_id)
305 306 307 308 309
        return context

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs()
        # Update the kwargs for the form init method with ours
310
        kwargs.update(self.kwargs)  # self.kwargs contains all URL conf params
311
        kwargs['request'] = self.request
312 313 314
        return kwargs

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

317 318
    def test_func(self):
        # TODO: Comprobar estado del proyecto, fecha.
319 320 321
        return self.es_coordinador(self.kwargs['proyecto_id']) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
322

323

324
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
325
    """Aceptar la invitación a participar en un proyecto."""
326 327

    def get_redirect_url(self, *args, **kwargs):
328
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
329 330

    def post(self, request, *args, **kwargs):
331
        usuario_actual = self.request.user
332
        proyecto_id = kwargs.get('proyecto_id')
333
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
334

335
        num_equipos = usuario_actual.get_num_equipos(proyecto.convocatoria_id)
336
        num_max_equipos = proyecto.convocatoria.num_max_equipos
337 338 339 340
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
341
                    f'''No puede aceptar esta invitación porque ya forma parte del número
342 343
                máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder aceptar esta invitación, antes debería renunciar a participar
344
                en algún otro proyecto.'''
345 346 347 348
                ),
            )
            return super().post(request, *args, **kwargs)

349
        pp = get_object_or_404(
350 351 352 353
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
354
        )
355
        pp.tipo_participacion_id = 'participante'
356 357
        pp.save()

358 359 360
        messages.success(
            request, _(f'Ha pasado a ser participante del proyecto «{proyecto.titulo}».')
        )
361 362 363 364
        return super().post(request, *args, **kwargs)


class ParticipanteDeclinarView(LoginRequiredMixin, RedirectView):
365
    """Declinar la invitación a participar en un proyecto."""
366 367

    def get_redirect_url(self, *args, **kwargs):
368
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
369 370

    def post(self, request, *args, **kwargs):
371
        proyecto_id = request.POST.get('proyecto_id')
372 373 374
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
375 376 377 378
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
379
        )
380
        pp.tipo_participacion_id = 'invitacion_rehusada'
381 382
        pp.save()

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


389
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
390
    """Renunciar a participar en un proyecto."""
391 392

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

    def post(self, request, *args, **kwargs):
396
        proyecto_id = request.POST.get('proyecto_id')
397 398 399
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
400 401 402 403
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='participante',
404
        )
405
        pp.tipo_participacion_id = 'invitacion_rehusada'
406 407
        pp.save()

408 409 410
        messages.success(
            request, _(f'Ha renunciado a participar en el proyecto «{proyecto.titulo}».')
        )
411 412 413
        return super().post(request, *args, **kwargs)


414
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
415
    """Borra un registro de ParticipanteProyecto"""
416 417

    model = ParticipanteProyecto
418
    template_name = 'participante-proyecto/confirm_delete.html'
419 420

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

423
    def test_func(self):
424 425 426
        return self.es_coordinador(self.get_object().proyecto.id) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
427

428

429
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
430
    """Crea una nueva solicitud de proyecto"""
431

432
    model = Proyecto
433
    template_name = 'proyecto/new.html'
434
    form_class = ProyectoForm
435 436

    def form_valid(self, form):
437 438
        # This method is called when valid form data has been POSTed,
        # to do custom logic on form data. It should return an HttpResponse.
439 440 441
        proyecto = form.save()
        self._guardar_coordinador(proyecto)
        self._registrar_creacion(proyecto)
442
        return redirect('proyecto_detail', proyecto.id)
443

444
    def get_form(self, form_class=None):
445
        """
446
        Devuelve el formulario añadiendo automáticamente el campo Convocatoria,
447
        que es requerido, y el usuario, para comprobar si tiene los permisos necesarios.
448
        """
449
        form = super(ProyectoCreateView, self).get_form(form_class)
450
        form.instance.user = self.request.user
451 452 453
        form.instance.convocatoria = Convocatoria(date.today().year)
        return form

454
    def _guardar_coordinador(self, proyecto):
455
        pp = ParticipanteProyecto(
456 457 458
            proyecto=proyecto,
            tipo_participacion=TipoParticipacion(nombre='coordinador'),
            usuario=self.request.user,
459
        )
460
        pp.save()
461 462

    def _registrar_creacion(self, proyecto):
463
        evento = Evento.objects.get(nombre='creacion_solicitud')
464 465 466
        registro = Registro(
            descripcion='Creación inicial de la solicitud', evento=evento, proyecto=proyecto
        )
467 468
        registro.save()

469 470 471 472 473
    def test_func(self):
        # TODO: Comprobar usuario para Proyectos de titulación y POU.
        # TODO: Comprobar fecha
        return self.es_pas_o_pdi()

474

475
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
476
    """Cambia el estado de una solicitud de proyecto a Anulada."""
477 478 479 480

    model = Proyecto

    def get_redirect_url(self, *args, **kwargs):
481
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
482 483

    def post(self, request, *args, **kwargs):
484 485
        proyecto = Proyecto.objects.get(pk=kwargs.get('pk'))
        proyecto.estado = 'ANULADO'
486 487
        proyecto.save()

488
        messages.success(request, _('Su solicitud de proyecto ha sido anulada.'))
489 490 491
        return super().post(request, *args, **kwargs)

    def test_func(self):
492
        return self.es_coordinador(self.kwargs['pk'])
493 494


495
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
496
    """Muestra una solicitud de proyecto."""
497

498
    model = Proyecto
499
    template_name = 'proyecto/detail.html'
500 501 502 503

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

504 505
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
506

507 508
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
509

510
        participantes = (
511 512
            self.object.participantes.filter(tipo_participacion='participante')
            .order_by('usuario__first_name', 'usuario__last_name')
513 514
            .all()
        )
515
        context['participantes'] = participantes
516 517

        invitados = (
518 519 520
            self.object.participantes.filter(
                tipo_participacion__in=['invitado', 'invitacion_rehusada']
            )
521
            .order_by('tipo_participacion', 'usuario__first_name', 'usuario__last_name')
522 523
            .all()
        )
524
        context['invitados'] = invitados
525

526
        context['campos'] = json.loads(self.object.programa.campos)
527

528
        context['permitir_edicion'] = (
529
            self.es_coordinador(self.object.id) and self.object.en_borrador()
530
        ) or self.request.user.has_perm('indo.editar_proyecto')
531

532 533 534
        context['es_coordinador'] = (
            self.es_coordinador(self.object.id) and self.object.en_borrador()
        )
535

536
        return context
537

538
    def test_func(self):
539
        proyecto_id = self.kwargs['pk']
540
        return self.esta_vinculado_o_es_decano_o_es_coordinador(proyecto_id)
541 542


543 544
class ProyectoTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""
545

546 547
    permission_required = 'indo.listar_proyectos'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
548
    table_class = ProyectosTable
549
    template_name = 'gestion/proyecto/tabla_proyectos.html'
550 551 552

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
553
        context['anyo'] = self.kwargs['anyo']
554 555 556 557
        return context

    def get_queryset(self):
        return (
558 559 560
            Proyecto.objects.filter(convocatoria__id=self.kwargs['anyo'])
            .exclude(estado__in=['BORRADOR', 'ANULADO'])
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
561 562 563
        )


564
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
565
    """Presenta una solicitud de proyecto.
566

567
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
568
    Se envían correos a los agentes involucrados.
569
    """
570

571
    def get_redirect_url(self, *args, **kwargs):
572
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
573 574

    def post(self, request, *args, **kwargs):
575
        proyecto_id = kwargs.get('pk')
576 577 578
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
579

580
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
581
        num_max_equipos = proyecto.convocatoria.num_max_equipos
582 583 584 585
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
586
                    f'''No puede presentar esta solicitud porque ya forma parte
587 588
                del número máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder presentar esta solicitud de proyecto, antes debería renunciar
589
                a participar en algún otro proyecto.'''
590 591 592 593
                ),
            )
            return super().post(request, *args, **kwargs)

594
        if request.user.get_colectivo_principal() == 'ADS' and proyecto.ayuda != 0:
595 596
            messages.error(
                request,
597 598 599 600
                _(
                    'Los profesores de los centros adscritos no pueden coordinar '
                    'proyectos con financiación.'
                ),
601
            )
602 603
            return super().post(request, *args, **kwargs)

604
        if proyecto.ayuda > proyecto.programa.max_ayuda:
605 606 607
            messages.error(
                request,
                _(
608 609
                    f'La ayuda solicitada ({proyecto.ayuda} €) excede el máximo '
                    f'permitido para este programa ({proyecto.programa.max_ayuda} €).'
610 611 612 613 614
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
615 616 617
            messages.error(
                request, _('La solicitud debe incluir al menos un invitado a participar.')
            )
618
            return super().post(request, *args, **kwargs)
619

620
        self._enviar_invitaciones(request, proyecto)
621 622 623 624 625 626 627

        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)

628
        # TODO Enviar "resguardo" al solicitante. PDF?
629

630
        proyecto.estado = 'SOLICITADO'
631 632 633
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
638
        """Envia un mensaje a cada uno de los invitados al proyecto."""
639
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
640
            send_templated_mail(
641
                template_name='invitacion',
642 643 644
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
645 646 647 648
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
649 650 651 652 653
                    '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'),
654
                    'site_url': settings.SITE_URL,
655 656 657
                },
            )

658
    def _enviar_solicitudes_visto_bueno_centro(self, request, proyecto):
659
        """Envia un mensaje al responsable del centro solicitando su visto bueno."""
660 661 662 663
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
664 665 666 667 668
                request,
                _(
                    'La dirección de correo electrónico del director o decano '
                    'del centro no es válida.'
                ),
669 670 671
            )
            return

672
        send_templated_mail(
673
            template_name='solicitud_visto_bueno_centro',
674 675 676
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
677 678 679 680
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
681 682 683 684 685
                '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'),
686
                'site_url': settings.SITE_URL,
687 688
            },
        )
689

690
    def _is_email_valid(self, email):
691
        """Validate email address"""
692 693 694 695 696 697 698
        try:
            validate_email(email)
        except ValidationError:
            return False
        return True

    def _enviar_solicitudes_visto_bueno_estudio(self, request, proyecto):
699
        """Envia mensaje a los coordinadores del plan solicitando su visto bueno."""
700
        email_coordinadores_estudio = [
701
            f'{p.email_coordinador}'
702 703 704 705 706
            for p in proyecto.estudio.planes.all()
            if self._is_email_valid(p.email_coordinador)
        ]

        send_templated_mail(
707
            template_name='solicitud_visto_bueno_estudio',
708 709 710
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
711 712
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
713 714 715 716 717
                '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'),
718
                'site_url': settings.SITE_URL,
719 720 721
            },
        )

722 723
    def test_func(self):
        # TODO: Comprobar fecha
724
        return self.es_coordinador(self.kwargs['pk'])
725

726 727

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

730 731
    # TODO: Comprobar estado/fecha
    model = Proyecto
732
    template_name = 'proyecto/update.html'
733 734

    def get_form_class(self, **kwargs):
735 736 737 738
        campo = self.kwargs['campo']
        if campo in ('centro', 'codigo', 'convocatoria', 'estado', 'estudio', 'linea', 'programa'):
            raise Http404(_('No puede editar ese campo.'))

739 740 741 742 743 744 745 746 747 748 749
        if campo not in (
            'titulo',
            'departamento',
            'licencia',
            'ayuda',
            'visto_bueno_centro',
            'visto_bueno_estudio',
        ):
            formulario = modelform_factory(
                Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()}
            )
750 751

            def as_p(self):
752
                """
753 754
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
755
                """
756
                return self._html_output(
757
                    normal_row='''<p%(html_class_attr)s>
758 759 760
                    %(label)s
                    %(help_text)s
                    %(field)s
761 762 763
                    </p>''',
                    error_row='%s',
                    row_ender='</p>',
764 765 766 767 768
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
769 770 771 772 773 774 775 776

            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,
777 778 779
                        tags=(
                            bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')
                        ),
780 781 782
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
783 784 785 786 787 788 789
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

790
            return formulario
791 792
        self.fields = (campo,)
        return super().get_form_class()
793

794
    def test_func(self):
795
        """Devuelve si el usuario está autorizado a modificar este campo."""
796
        return (
797
            self.es_coordinador(self.kwargs['pk'])
798 799 800 801 802 803 804 805
            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'])
            )
806
            or self.request.user.has_perm('indo.editar_proyecto')
807
        )
808

809

810
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
811
    """Lista los proyectos a los que está vinculado el usuario actual."""
812

813
    template_name = 'proyecto/mis-proyectos.html'
814 815

    def get_context_data(self, **kwargs):
816
        usuario = self.request.user
817
        anyo = self.kwargs['anyo']
818
        context = super().get_context_data(**kwargs)
819
        context['proyectos_coordinados'] = (
820
            Proyecto.objects.filter(
821
                convocatoria__id=anyo,
822
                participantes__usuario=usuario,
823
                participantes__tipo_participacion_id__in=['coordinador', 'coordinador_2'],
824
            )
825 826
            .exclude(estado='ANULADO')
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
827 828
            .all()
        )
829
        context['proyectos_participados'] = (
830
            Proyecto.objects.filter(
831
                convocatoria__id=anyo,
832
                participantes__usuario=usuario,
833
                participantes__tipo_participacion_id='participante',
834
            )
835
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
836 837
            .all()
        )
838
        context['proyectos_invitado'] = (
839
            Proyecto.objects.filter(
840 841 842
                convocatoria__id=anyo,
                participantes__usuario=usuario,
                participantes__tipo_participacion_id='invitado',
843
            )
844
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
845 846
            .all()
        )
847

848
        try:
849
            nip_usuario = int(usuario.username)
850
        except ValueError:
851
            nip_usuario = 0
852