views.py 34.5 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 49 50 51 52 53
from .tables import (
    EvaluadoresTable,
    EvaluacionProyectosTable,
    ProyectosEvaluadosTable,
    ProyectosTable,
)
54

55

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

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

        return usuario_actual in usuarios_coordinadores

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

80
        return True if pp else False
81 82

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

91 92 93
        return True if pp else False

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

        return True if pp else False
105 106

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

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

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

125
        nip_decano = centro.nip_decano
126 127
        return usuario_actual.username == str(nip_decano)

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

146 147
        return esta_autorizado

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

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

164 165 166 167 168
    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
169

170
        return usuario_actual == proyecto.evaluador
171

172

173
class AyudaView(TemplateView):
174
    template_name = 'ayuda.html'
175 176


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


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
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'])


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 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313
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})


314
class HomePageView(TemplateView):
315
    template_name = 'home.html'
316 317


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

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

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

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

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

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

347

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

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

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

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

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

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


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

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

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

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


413
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
414
    """Renunciar a participar en un proyecto."""
415 416

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

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

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


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

    model = ParticipanteProyecto
442
    template_name = 'participante-proyecto/confirm_delete.html'
443 444

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

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

452

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

456
    model = Proyecto
457
    template_name = 'proyecto/new.html'
458
    form_class = ProyectoForm
459 460

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

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

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

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

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

498

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

    model = Proyecto

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

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

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

    def test_func(self):
516
        return self.es_coordinador(self.kwargs['pk'])
517 518


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

522
    model = Proyecto
523
    template_name = 'proyecto/detail.html'
524 525 526 527

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

528 529
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
530

531 532
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
533

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

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

550
        context['campos'] = json.loads(self.object.programa.campos)
551

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

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

560
        return context
561

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


567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587
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')
        )


588 589
class ProyectoTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""
590

591 592
    permission_required = 'indo.listar_proyectos'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
593
    table_class = ProyectosTable
594
    template_name = 'gestion/proyecto/tabla_proyectos.html'
595 596 597

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
598
        context['anyo'] = self.kwargs['anyo']
599 600 601 602
        return context

    def get_queryset(self):
        return (
603 604 605
            Proyecto.objects.filter(convocatoria__id=self.kwargs['anyo'])
            .exclude(estado__in=['BORRADOR', 'ANULADO'])
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
606 607 608
        )


609
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
610
    """Presenta una solicitud de proyecto.
611

612
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
613
    Se envían correos a los agentes involucrados.
614
    """
615

616
    def get_redirect_url(self, *args, **kwargs):
617
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
618 619

    def post(self, request, *args, **kwargs):
620
        proyecto_id = kwargs.get('pk')
621 622 623
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
624

625
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
626
        num_max_equipos = proyecto.convocatoria.num_max_equipos
627 628 629 630
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
631
                    f'''No puede presentar esta solicitud porque ya forma parte
632 633
                del número máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder presentar esta solicitud de proyecto, antes debería renunciar
634
                a participar en algún otro proyecto.'''
635 636 637 638
                ),
            )
            return super().post(request, *args, **kwargs)

639
        if request.user.get_colectivo_principal() == 'ADS' and proyecto.ayuda != 0:
640 641
            messages.error(
                request,
642 643 644 645
                _(
                    'Los profesores de los centros adscritos no pueden coordinar '
                    'proyectos con financiación.'
                ),
646
            )
647 648
            return super().post(request, *args, **kwargs)

649
        if proyecto.ayuda > proyecto.programa.max_ayuda:
650 651 652
            messages.error(
                request,
                _(
653 654
                    f'La ayuda solicitada ({proyecto.ayuda} €) excede el máximo '
                    f'permitido para este programa ({proyecto.programa.max_ayuda} €).'
655 656 657 658 659
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
660 661 662
            messages.error(
                request, _('La solicitud debe incluir al menos un invitado a participar.')
            )
663
            return super().post(request, *args, **kwargs)
664

665
        self._enviar_invitaciones(request, proyecto)
666 667 668 669 670 671 672

        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)

673
        # TODO Enviar "resguardo" al solicitante. PDF?
674

675
        proyecto.estado = 'SOLICITADO'
676 677 678
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
683
        """Envia un mensaje a cada uno de los invitados al proyecto."""
684
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
685
            send_templated_mail(
686
                template_name='invitacion',
687 688 689
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
690 691 692 693
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
694 695 696 697 698
                    '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'),
699
                    'site_url': settings.SITE_URL,
700 701 702
                },
            )

703
    def _enviar_solicitudes_visto_bueno_centro(self, request, proyecto):
704
        """Envia un mensaje al responsable del centro solicitando su visto bueno."""
705 706 707 708
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
709 710 711 712 713
                request,
                _(
                    'La dirección de correo electrónico del director o decano '
                    'del centro no es válida.'
                ),
714 715 716
            )
            return

717
        send_templated_mail(
718
            template_name='solicitud_visto_bueno_centro',
719 720 721
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
722 723 724 725
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
726 727 728 729 730
                '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'),
731
                'site_url': settings.SITE_URL,
732 733
            },
        )
734

735
    def _is_email_valid(self, email):
736
        """Validate email address"""
737 738 739 740 741 742 743
        try:
            validate_email(email)
        except ValidationError:
            return False
        return True

    def _enviar_solicitudes_visto_bueno_estudio(self, request, proyecto):
744
        """Envia mensaje a los coordinadores del plan solicitando su visto bueno."""
745
        email_coordinadores_estudio = [
746
            f'{p.email_coordinador}'
747 748 749 750 751
            for p in proyecto.estudio.planes.all()
            if self._is_email_valid(p.email_coordinador)
        ]

        send_templated_mail(
752
            template_name='solicitud_visto_bueno_estudio',
753 754 755
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
756 757
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
758 759 760 761 762
                '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'),
763
                'site_url': settings.SITE_URL,
764 765 766
            },
        )

767 768
    def test_func(self):
        # TODO: Comprobar fecha
769
        return self.es_coordinador(self.kwargs['pk'])
770

771 772

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

775 776
    # TODO: Comprobar estado/fecha
    model = Proyecto
777
    template_name = 'proyecto/update.html'
778 779

    def get_form_class(self, **kwargs):
780 781 782 783
        campo = self.kwargs['campo']
        if campo in ('centro', 'codigo', 'convocatoria', 'estado', 'estudio', 'linea', 'programa'):
            raise Http404(_('No puede editar ese campo.'))

784 785 786 787 788 789 790 791 792 793 794
        if campo not in (
            'titulo',
            'departamento',
            'licencia',
            'ayuda',
            'visto_bueno_centro',
            'visto_bueno_estudio',
        ):
            formulario = modelform_factory(
                Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()}
            )
795 796

            def as_p(self):
797
                """
798 799
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
800
                """
801
                return self._html_output(
802
                    normal_row='''<p%(html_class_attr)s>
803 804 805
                    %(label)s
                    %(help_text)s
                    %(field)s
806 807 808
                    </p>''',
                    error_row='%s',
                    row_ender='</p>',
809 810 811 812 813
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
814 815 816 817 818 819 820 821

            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,
822 823 824
                        tags=(
                            bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')
                        ),
825 826 827
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
828 829 830 831 832 833 834
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

835
            return formulario
836 837
        self.fields = (campo,)
        return super().get_form_class()
838

839
    def test_func(self):
840
        """Devuelve si el usuario está autorizado a modificar este campo."""
841
        return (
842
            self.es_coordinador(self.kwargs['pk'])
843 844 845 846 847 848 849 850
            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'])
            )
851
            or self.request.user.has_perm('indo.editar_proyecto')
852
        )
853

854

855
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
856
    """Lista los proyectos a los que está vinculado el usuario actual."""
857

858
    template_name = 'proyecto/mis-proyectos.html'
859 860

    def get_context_data(self, **kwargs):
861
        usuario = self.request.user
862
        anyo = self.kwargs['anyo']
863
        context = super().get_context_data(**kwargs)
864
        context['proyectos_coordinados'] = (
865
            Proyecto.objects.filter(
866
                convocatoria__id=anyo,
867
                participantes__usuario=usuario,
868
                participantes__tipo_participacion_id__in=['coordinador', 'coordinador_2'],
869
            )
870 871
            .exclude(estado='ANULADO')
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
872 873
            .all()
        )
874
        context['proyectos_participados'] = (
875
            Proyecto.objects.filter(
876
                convocatoria__id=anyo,
877
                participantes__usuario=usuario,
878
                participantes__tipo_participacion_id='participante',
879
            )
880
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
881 882
            .all()
        )
883
        context[