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

6 7 8 9 10
# Third-party
from annoying.functions import get_config, get_object_or_None
from django_summernote.widgets import SummernoteWidget
from django_tables2.views import SingleTableView
from templated_email import send_templated_mail
11
import bleach
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
12
import pypandoc
13

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

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

58

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

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

        return usuario_actual in usuarios_coordinadores

    def es_participante(self, proyecto_id):
75
        """Devuelve si el usuario actual es participante del proyecto indicado."""
76
        self.permission_denied_message = _('Usted no es participante de este proyecto.')
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='participante'
        ).all()
82

83
        return True if pp else False
84 85

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

94 95 96
        return True if pp else False

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

        return True if pp else False
108 109

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

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

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

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

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

149 150
        return esta_autorizado

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

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

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

173
        return usuario_actual == proyecto.evaluador
174

175

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


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


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

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

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


242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
class ProyectoEvaluadorTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla con las solicitudes de proyectos presentadas y el evaluador asignado."""

    permission_required = 'indo.listar_evaluadores'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    table_class = EvaluadoresTable
    template_name = 'gestion/proyecto/tabla_evaluadores.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['anyo'] = self.kwargs['anyo']
        return context

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


class ProyectosEvaluadosTableView(LoginRequiredMixin, UserPassesTestMixin, SingleTableView):
    """Lista los proyectos asignados al usuario (evaluador) actual."""

    permission_denied_message = _('Sólo los evaluadores pueden acceder a esta página.')
    table_class = ProyectosEvaluadosTable
    template_name = 'evaluador/mis_proyectos.html'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['anyo'] = self.kwargs['anyo']
        return context

    def get_queryset(self):
        return (
            Proyecto.objects.filter(convocatoria__id=self.kwargs['anyo'])
            .filter(evaluador=self.request.user)
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
        )

    def test_func(self):
        return self.request.user.groups.filter(name='Evaluadores').exists()


class ProyectoEvaluadorUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    """Actualizar el evaluador de un proyecto."""

    permission_required = 'indo.editar_evaluador'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    model = Proyecto
    template_name = 'gestion/proyecto/editar_evaluador.html'
    form_class = EvaluadorForm

    def get(self, request, *args, **kwargs):
296
        User = get_user_model()
297
        # Obtenemos los NIPs de los usuarios con vinculación «Evaluador externo innovacion ACPUA».
298 299 300
        advertencia, nip_evaluadores = User.get_nips_vinculacion(60)
        if advertencia:
            messages.warning(request, advertencia)
301 302 303
        nip_evaluadores = [str(nip) for nip in nip_evaluadores]
        # nip_evaluadores = ['136040', '327618', '329639', '370109']  # XXX - Desarrollo

304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323
        # Creamos los usuarios que no existan ya en la aplicación.
        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():
            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})


324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
class ProyectoResolucionUpdateView(
    LoginRequiredMixin, PermissionRequiredMixin, SuccessMessageMixin, UpdateView
):
    """Actualizar la resolución de la Comisión Evaluadora sobre un proyecto."""

    permission_required = 'indo.editar_resolucion'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
    model = Proyecto
    success_message = _(
        'Se ha guardado la resolución de la comisión sobre el proyecto «%(titulo)s».'
    )
    template_name = 'gestion/proyecto/editar_resolucion.html'
    form_class = ResolucionForm

    def get_success_message(self, cleaned_data):
        return self.success_message % dict(cleaned_data, titulo=self.object.titulo)

    def get_success_url(self):
        return reverse_lazy('evaluaciones_table', args=[self.object.convocatoria_id])


345
class HomePageView(TemplateView):
346
    template_name = 'home.html'
347 348


349
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
350
    """Muestra un formulario para invitar a una persona a un proyecto determinado."""
351 352 353

    form_class = InvitacionForm
    model = ParticipanteProyecto
354
    template_name = 'participante-proyecto/invitar.html'
355 356 357

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
358 359
        proyecto_id = self.kwargs['proyecto_id']
        context['proyecto'] = Proyecto.objects.get(id=proyecto_id)
360 361 362 363 364
        return context

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs()
        # Update the kwargs for the form init method with ours
365
        kwargs.update(self.kwargs)  # self.kwargs contains all URL conf params
366
        kwargs['request'] = self.request
367 368 369
        return kwargs

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

372 373
    def test_func(self):
        # TODO: Comprobar estado del proyecto, fecha.
374 375 376
        return self.es_coordinador(self.kwargs['proyecto_id']) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
377

378

379
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
380
    """Aceptar la invitación a participar en un proyecto."""
381 382

    def get_redirect_url(self, *args, **kwargs):
383
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
384 385

    def post(self, request, *args, **kwargs):
386
        usuario_actual = self.request.user
387
        proyecto_id = kwargs.get('proyecto_id')
388
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
389

390
        num_equipos = usuario_actual.get_num_equipos(proyecto.convocatoria_id)
391
        num_max_equipos = proyecto.convocatoria.num_max_equipos
392 393 394 395
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
396
                    f'''No puede aceptar esta invitación porque ya forma parte del número
397 398
                máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder aceptar esta invitación, antes debería renunciar a participar
399
                en algún otro proyecto.'''
400 401 402 403
                ),
            )
            return super().post(request, *args, **kwargs)

404
        pp = get_object_or_404(
405 406 407 408
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='invitado',
409
        )
410
        pp.tipo_participacion_id = 'participante'
411 412
        pp.save()

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


class ParticipanteDeclinarView(LoginRequiredMixin, RedirectView):
420
    """Declinar la invitación a participar en un proyecto."""
421 422

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

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

438 439 440
        messages.success(
            request, _(f'Ha rehusado ser participante del proyecto «{proyecto.titulo}».')
        )
441 442 443
        return super().post(request, *args, **kwargs)


444
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
445
    """Renunciar a participar en un proyecto."""
446 447

    def get_redirect_url(self, *args, **kwargs):
448
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
449 450

    def post(self, request, *args, **kwargs):
451
        proyecto_id = request.POST.get('proyecto_id')
452 453 454
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
455 456 457 458
            ParticipanteProyecto,
            proyecto_id=proyecto_id,
            usuario=usuario_actual,
            tipo_participacion='participante',
459
        )
460
        pp.tipo_participacion_id = 'invitacion_rehusada'
461 462
        pp.save()

463 464 465
        messages.success(
            request, _(f'Ha renunciado a participar en el proyecto «{proyecto.titulo}».')
        )
466 467 468
        return super().post(request, *args, **kwargs)


469
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
470
    """Borra un registro de ParticipanteProyecto"""
471 472

    model = ParticipanteProyecto
473
    template_name = 'participante-proyecto/confirm_delete.html'
474 475

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

478
    def test_func(self):
479 480 481
        return self.es_coordinador(self.get_object().proyecto.id) or self.request.user.has_perm(
            'indo.editar_proyecto'
        )
482

483

484
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
485
    """Crea una nueva solicitud de proyecto"""
486

487
    model = Proyecto
488
    template_name = 'proyecto/new.html'
489
    form_class = ProyectoForm
490 491

    def form_valid(self, form):
492 493
        # This method is called when valid form data has been POSTed,
        # to do custom logic on form data. It should return an HttpResponse.
494 495 496
        proyecto = form.save()
        self._guardar_coordinador(proyecto)
        self._registrar_creacion(proyecto)
497
        return redirect('proyecto_detail', proyecto.id)
498

499
    def get_form(self, form_class=None):
500
        """
501
        Devuelve el formulario añadiendo automáticamente el campo Convocatoria,
502
        que es requerido, y el usuario, para comprobar si tiene los permisos necesarios.
503
        """
504
        form = super(ProyectoCreateView, self).get_form(form_class)
505
        form.instance.user = self.request.user
506 507 508
        form.instance.convocatoria = Convocatoria(date.today().year)
        return form

509
    def _guardar_coordinador(self, proyecto):
510
        pp = ParticipanteProyecto(
511 512 513
            proyecto=proyecto,
            tipo_participacion=TipoParticipacion(nombre='coordinador'),
            usuario=self.request.user,
514
        )
515
        pp.save()
516 517

    def _registrar_creacion(self, proyecto):
518
        evento = Evento.objects.get(nombre='creacion_solicitud')
519 520 521
        registro = Registro(
            descripcion='Creación inicial de la solicitud', evento=evento, proyecto=proyecto
        )
522 523
        registro.save()

524 525 526 527 528
    def test_func(self):
        # TODO: Comprobar usuario para Proyectos de titulación y POU.
        # TODO: Comprobar fecha
        return self.es_pas_o_pdi()

529

530
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
531
    """Cambia el estado de una solicitud de proyecto a Anulada."""
532 533 534 535

    model = Proyecto

    def get_redirect_url(self, *args, **kwargs):
536
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
537 538

    def post(self, request, *args, **kwargs):
539 540
        proyecto = Proyecto.objects.get(pk=kwargs.get('pk'))
        proyecto.estado = 'ANULADO'
541 542
        proyecto.save()

543
        messages.success(request, _('Su solicitud de proyecto ha sido anulada.'))
544 545 546
        return super().post(request, *args, **kwargs)

    def test_func(self):
547
        return self.es_coordinador(self.kwargs['pk'])
548 549


550
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
551
    """Muestra una solicitud de proyecto."""
552

553
    model = Proyecto
554
    template_name = 'proyecto/detail.html'
555 556 557 558

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

559 560
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
561

562 563
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
564

565
        participantes = (
566 567
            self.object.participantes.filter(tipo_participacion='participante')
            .order_by('usuario__first_name', 'usuario__last_name')
568 569
            .all()
        )
570
        context['participantes'] = participantes
571 572

        invitados = (
573 574 575
            self.object.participantes.filter(
                tipo_participacion__in=['invitado', 'invitacion_rehusada']
            )
576
            .order_by('tipo_participacion', 'usuario__first_name', 'usuario__last_name')
577 578
            .all()
        )
579
        context['invitados'] = invitados
580

581
        context['campos'] = json.loads(self.object.programa.campos)
582

583
        context['permitir_edicion'] = (
584
            self.es_coordinador(self.object.id) and self.object.en_borrador()
585
        ) or self.request.user.has_perm('indo.editar_proyecto')
586

587 588 589
        context['es_coordinador'] = (
            self.es_coordinador(self.object.id) and self.object.en_borrador()
        )
590

591
        return context
592

593
    def test_func(self):
594
        proyecto_id = self.kwargs['pk']
595
        return self.esta_vinculado_o_es_decano_o_es_coordinador(proyecto_id)
596 597


598 599 600 601 602 603 604 605 606 607 608 609 610 611 612
class ProyectoEvaluacionesCsvView(LoginRequiredMixin, PermissionRequiredMixin, View):
    """Devuelve un fichero CSV con las valoraciones de todos los proyectos presentados."""

    permission_required = 'indo.listar_evaluaciones'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')

    def get(self, request, *args, **kwargs):
        valoraciones = Valoracion.get_todas(kwargs.get('anyo'))
        response = HttpResponse(content_type='text/csv')
        response['Content-Disposition'] = 'attachment; filename="valoraciones.csv"'
        writer = csv.writer(response)
        writer.writerows(valoraciones)
        return response


613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633
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')
        )


634 635
class ProyectoTableView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
    """Muestra una tabla de todos los proyectos presentados en una convocatoria."""
636

637 638
    permission_required = 'indo.listar_proyectos'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
639
    table_class = ProyectosTable
640
    template_name = 'gestion/proyecto/tabla_proyectos.html'
641 642 643

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
644
        context['anyo'] = self.kwargs['anyo']
645 646 647 648
        return context

    def get_queryset(self):
        return (
649 650 651
            Proyecto.objects.filter(convocatoria__id=self.kwargs['anyo'])
            .exclude(estado__in=['BORRADOR', 'ANULADO'])
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
652 653 654
        )


655
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
656
    """Presenta una solicitud de proyecto.
657

658
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
659
    Se envían correos a los agentes involucrados.
660
    """
661

662
    def get_redirect_url(self, *args, **kwargs):
663
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
664 665

    def post(self, request, *args, **kwargs):
666
        proyecto_id = kwargs.get('pk')
667 668 669
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
670

671
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
672
        num_max_equipos = proyecto.convocatoria.num_max_equipos
673 674 675 676
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
677
                    f'''No puede presentar esta solicitud porque ya forma parte
678 679 680
                    del número máximo de equipos de trabajo permitido ({num_max_equipos}).
                    Para poder presentar esta solicitud de proyecto, antes debería renunciar
                    a participar en algún otro proyecto.'''
681 682 683 684
                ),
            )
            return super().post(request, *args, **kwargs)

685
        if request.user.get_colectivo_principal() == 'ADS' and proyecto.ayuda != 0:
686 687
            messages.error(
                request,
688 689 690 691
                _(
                    'Los profesores de los centros adscritos no pueden coordinar '
                    'proyectos con financiación.'
                ),
692
            )
693 694
            return super().post(request, *args, **kwargs)

695
        if proyecto.ayuda > proyecto.programa.max_ayuda:
696 697 698
            messages.error(
                request,
                _(
699 700
                    f'La ayuda solicitada ({proyecto.ayuda} €) excede el máximo '
                    f'permitido para este programa ({proyecto.programa.max_ayuda} €).'
701 702 703 704 705
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
706 707 708
            messages.error(
                request, _('La solicitud debe incluir al menos un invitado a participar.')
            )
709
            return super().post(request, *args, **kwargs)
710

711
        self._enviar_invitaciones(request, proyecto)
712 713 714 715 716 717 718

        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)

719
        # TODO Enviar "resguardo" al solicitante. PDF?
720

721
        proyecto.estado = 'SOLICITADO'
722 723 724
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
729
        """Envia un mensaje a cada uno de los invitados al proyecto."""
730
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
731
            send_templated_mail(
732
                template_name='invitacion',
733 734 735
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
736 737 738 739
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
740 741 742 743 744
                    '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'),
745
                    'site_url': settings.SITE_URL,
746 747 748
                },
            )

749
    def _enviar_solicitudes_visto_bueno_centro(self, request, proyecto):
750
        """Envia un mensaje al responsable del centro solicitando su visto bueno."""
751 752 753 754
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
755 756 757 758 759
                request,
                _(
                    'La dirección de correo electrónico del director o decano '
                    'del centro no es válida.'
                ),
760 761 762
            )
            return

763
        send_templated_mail(
764
            template_name='solicitud_visto_bueno_centro',
765 766 767
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
768 769 770 771
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
772 773 774 775 776
                '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'),
777
                'site_url': settings.SITE_URL,
778 779
            },
        )
780

781
    def _is_email_valid(self, email):
782
        """Validate email address"""
783 784 785 786 787 788 789
        try:
            validate_email(email)
        except ValidationError:
            return False
        return True

    def _enviar_solicitudes_visto_bueno_estudio(self, request, proyecto):
790
        """Envia mensaje a los coordinadores del plan solicitando su visto bueno."""
791
        email_coordinadores_estudio = [
792
            f'{p.email_coordinador}'
793 794 795 796 797
            for p in proyecto.estudio.planes.all()
            if self._is_email_valid(p.email_coordinador)
        ]

        send_templated_mail(
798
            template_name='solicitud_visto_bueno_estudio',
799 800 801
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
802 803
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
804 805 806 807 808
                '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'),
809
                'site_url': settings.SITE_URL,
810 811 812
            },
        )

813 814
    def test_func(self):
        # TODO: Comprobar fecha
815
        return self.es_coordinador(self.kwargs['pk'])
816

817 818

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

821 822
    # TODO: Comprobar estado/fecha
    model = Proyecto
823
    template_name = 'proyecto/update.html'
824 825

    def get_form_class(self, **kwargs):
826 827 828 829
        campo = self.kwargs['campo']
        if campo in ('centro', 'codigo', 'convocatoria', 'estado', 'estudio', 'linea', 'programa'):
            raise Http404(_('No puede editar ese campo.'))

830 831 832 833 834 835 836 837 838 839 840
        if campo not in (
            'titulo',
            'departamento',
            'licencia',
            'ayuda',
            'visto_bueno_centro',
            'visto_bueno_estudio',
        ):
            formulario = modelform_factory(
                Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()}
            )
841 842

            def as_p(self):
843
                """
844 845
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
846
                """
847
                return self._html_output(
848
                    normal_row='''<p%(html_class_attr)s>
849 850 851
                    %(label)s
                    %(help_text)s
                    %(field)s
852 853 854
                    </p>''',
                    error_row='%s',
                    row_ender='</p>',
855 856 857 858 859
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
860 861 862 863 864 865 866 867

            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,
868 869 870
                        tags=(
                            bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')
                        ),
871 872 873
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
874 875 876 877 878 879 880
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

881
            return formulario
882 883
        self.fields = (campo,)
        return super().get_form_class()
884

885
    def test_func(self):
886
        """Devuelve si el usuario está autorizado a modificar este campo."""
887
        return (
888
            self.es_coordinador(self.kwargs['pk'])
889 890 891 892 893 894 895 896
            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'])
            )
897
            or self.request.user.has_perm('indo.editar_proyecto')
898
        )
899

900

901
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
902
    """Lista los proyectos a los que está vinculado el usuario actual."""
903

904
    template_name = 'proyecto/mis-proyectos.html'
905 906

    def get_context_data(self, **kwargs):