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

4
import bleach
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
5
import pypandoc
6

7
from django.conf import settings
8
from django.contrib import messages
9
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin
10 11
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
12
from django.forms.models import modelform_factory
13
from django.http import Http404
14
from django.shortcuts import get_object_or_404, redirect
15
from django.urls import reverse_lazy
16
from django.utils.safestring import mark_safe
17
from django.utils.translation import gettext_lazy as _
18
from django.views.generic import DetailView, RedirectView, TemplateView
Enrique Matías Sánchez (Quique)'s avatar
style  
Enrique Matías Sánchez (Quique) committed
19
from django.views.generic.edit import CreateView, DeleteView, UpdateView
20 21

from annoying.functions import get_config
22
from django_summernote.widgets import SummernoteWidget
23
from django_tables2.views import SingleTableView
24 25
from templated_email import send_templated_mail

26
from .forms import InvitacionForm, ProyectoForm
27
from .models import Centro, Convocatoria, Evento, ParticipanteProyecto, Plan, Proyecto, Registro, TipoParticipacion
28
from .tables import ProyectosTable
29

30

31
class ChecksMixin(UserPassesTestMixin):
32
    '''Proporciona comprobaciones para autorizar o no una acción a un usuario.'''
33 34

    def es_coordinador(self, proyecto_id):
35
        '''Devuelve si el usuario actual es coordinador del proyecto indicado.'''
36
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
37 38
        usuario_actual = self.request.user
        coordinadores_participantes = proyecto.participantes.filter(
39
            tipo_participacion__in=['coordinador', 'coordinador_2']
40
        ).all()
41 42
        usuarios_coordinadores = list(map(lambda p: p.usuario, coordinadores_participantes))
        self.permission_denied_message = _('Usted no es coordinador de este proyecto.')
43 44 45 46

        return usuario_actual in usuarios_coordinadores

    def es_participante(self, proyecto_id):
47
        '''Devuelve si el usuario actual es participante del proyecto indicado.'''
48
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
49
        usuario_actual = self.request.user
50 51
        pp = proyecto.participantes.filter(usuario=usuario_actual, tipo_participacion='participante').all()
        self.permission_denied_message = _('Usted no es participante de este proyecto.')
52

53
        return True if pp else False
54 55

    def es_invitado(self, proyecto_id):
56
        '''Devuelve si el usuario actual es invitado del proyecto indicado.'''
57
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
58
        usuario_actual = self.request.user
59 60
        pp = proyecto.participantes.filter(usuario=usuario_actual, tipo_participacion='invitado').all()
        self.permission_denied_message = _('Usted no está invitado a este proyecto.')
61

62 63 64
        return True if pp else False

    def esta_vinculado(self, proyecto_id):
65
        '''Devuelve si el usuario actual está vinculado al proyecto indicado.'''
66
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
67
        usuario_actual = self.request.user
68 69
        pp = (
            proyecto.participantes.filter(usuario=usuario_actual)
70
            .exclude(tipo_participacion='invitacion_rehusada')
71 72
            .all()
        )
73
        self.permission_denied_message = _('Usted no está vinculado a este proyecto.')
74 75

        return True if pp else False
76 77

    def es_pas_o_pdi(self):
78
        '''
79
        Devuelve si el usuario actual es PAS o PDI de la UZ o de sus centros adscritos.
80
        '''
81 82
        usuario_actual = self.request.user
        colectivos_del_usuario = json.loads(usuario_actual.colectivos)
83
        self.permission_denied_message = _('Usted no es PAS ni PDI.')
84

85
        return any(col_autorizado in colectivos_del_usuario for col_autorizado in ['PAS', 'ADS', 'PDI'])
86

87
    def es_decano_o_director(self, proyecto_id):
88
        '''Devuelve si el usuario actual es decano/director del centro del proyecto.'''
89 90 91 92 93 94
        usuario_actual = self.request.user
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        centro = proyecto.centro
        if not centro:
            return False
        nip_decano = centro.nip_decano
95
        self.permission_denied_message = _('Usted no es decano/director del centro del proyecto.')
96 97 98

        return usuario_actual.username == str(nip_decano)

99
    def esta_vinculado_o_es_decano_o_es_coordinador(self, proyecto_id):
100
        '''
101
        Devuelve si el usuario actual está vinculado al proyecto indicado
102
        o es decano o director del centro del proyecto
103
        o es coordinador del plan de estudios del proyecto.'''
104 105 106 107
        usuario_actual = self.request.user
        esta_autorizado = (
            self.esta_vinculado(proyecto_id)
            or self.es_decano_o_director(proyecto_id)
108
            or self.es_coordinador_estudio(proyecto_id)
109
            or usuario_actual.has_perm('indo.ver_proyecto')  # Gestores y evaluadores
110 111
        )
        self.permission_denied_message = _(
112 113 114
            'Usted no está vinculado a este proyecto, '
            'ni es decano/director del centro del proyecto, '
            'ni es coordinador del plan de estudios del proyecto.'
115 116 117
        )
        return esta_autorizado

118
    def es_coordinador_estudio(self, proyecto_id):
119
        '''Devuelve si el usuario actual es coordinador del estudio del proyecto.'''
120 121 122 123 124
        usuario_actual = self.request.user
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        estudio = proyecto.estudio
        if not estudio:
            return False
125
        nip_coordinadores = [f'{p.nip_coordinador}' for p in estudio.planes.all() if p.nip_coordinador]
126

127
        self.permission_denied_message = _('Usted no es coordinador del plan de estudios del proyecto.')
128 129 130

        return usuario_actual.username in nip_coordinadores

131

132
class AyudaView(TemplateView):
133
    template_name = 'ayuda.html'
134 135 136


class HomePageView(TemplateView):
137
    template_name = 'home.html'
138 139


140
class InvitacionView(LoginRequiredMixin, ChecksMixin, CreateView):
141
    '''Muestra un formulario para invitar a una persona a un proyecto determinado.'''
142 143 144

    form_class = InvitacionForm
    model = ParticipanteProyecto
145
    template_name = 'participante-proyecto/invitar.html'
146 147 148

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
149 150
        proyecto_id = self.kwargs['proyecto_id']
        context['proyecto'] = Proyecto.objects.get(id=proyecto_id)
151 152 153 154 155
        return context

    def get_form_kwargs(self, **kwargs):
        kwargs = super().get_form_kwargs()
        # Update the kwargs for the form init method with ours
156
        kwargs.update(self.kwargs)  # self.kwargs contains all URL conf params
157
        kwargs['request'] = self.request
158 159 160
        return kwargs

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

163 164
    def test_func(self):
        # TODO: Comprobar estado del proyecto, fecha.
165
        return self.es_coordinador(self.kwargs['proyecto_id']) or self.request.user.has_perm('indo.editar_proyecto')
166

167

168
class ParticipanteAceptarView(LoginRequiredMixin, RedirectView):
169
    '''Aceptar la invitación a participar en un proyecto.'''
170 171

    def get_redirect_url(self, *args, **kwargs):
172
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
173 174

    def post(self, request, *args, **kwargs):
175
        usuario_actual = self.request.user
176
        proyecto_id = kwargs.get('proyecto_id')
177
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
178

179
        num_equipos = usuario_actual.get_num_equipos(proyecto.convocatoria_id)
180
        num_max_equipos = proyecto.convocatoria.num_max_equipos
181 182 183 184
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
185
                    f'''No puede aceptar esta invitación porque ya forma parte del número
186 187
                máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder aceptar esta invitación, antes debería renunciar a participar
188
                en algún otro proyecto.'''
189 190 191 192
                ),
            )
            return super().post(request, *args, **kwargs)

193
        pp = get_object_or_404(
194
            ParticipanteProyecto, proyecto_id=proyecto_id, usuario=usuario_actual, tipo_participacion='invitado'
195
        )
196
        pp.tipo_participacion_id = 'participante'
197 198
        pp.save()

199
        messages.success(request, _(f'Ha pasado a ser participante del proyecto «{proyecto.titulo}».'))
200 201 202 203
        return super().post(request, *args, **kwargs)


class ParticipanteDeclinarView(LoginRequiredMixin, RedirectView):
204
    '''Declinar la invitación a participar en un proyecto.'''
205 206

    def get_redirect_url(self, *args, **kwargs):
207
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
208 209

    def post(self, request, *args, **kwargs):
210
        proyecto_id = request.POST.get('proyecto_id')
211 212 213
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
214
            ParticipanteProyecto, proyecto_id=proyecto_id, usuario=usuario_actual, tipo_participacion='invitado'
215
        )
216
        pp.tipo_participacion_id = 'invitacion_rehusada'
217 218
        pp.save()

219
        messages.success(request, _(f'Ha rehusado ser participante del proyecto «{proyecto.titulo}».'))
220 221 222
        return super().post(request, *args, **kwargs)


223
class ParticipanteRenunciarView(LoginRequiredMixin, RedirectView):
224
    '''Renunciar a participar en un proyecto.'''
225 226

    def get_redirect_url(self, *args, **kwargs):
227
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
228 229

    def post(self, request, *args, **kwargs):
230
        proyecto_id = request.POST.get('proyecto_id')
231 232 233
        proyecto = get_object_or_404(Proyecto, pk=proyecto_id)
        usuario_actual = self.request.user
        pp = get_object_or_404(
234
            ParticipanteProyecto, proyecto_id=proyecto_id, usuario=usuario_actual, tipo_participacion='participante'
235
        )
236
        pp.tipo_participacion_id = 'invitacion_rehusada'
237 238
        pp.save()

239
        messages.success(request, _(f'Ha renunciado a participar en el proyecto «{proyecto.titulo}».'))
240 241 242
        return super().post(request, *args, **kwargs)


243
class ParticipanteDeleteView(LoginRequiredMixin, ChecksMixin, DeleteView):
244
    '''Borra un registro de ParticipanteProyecto'''
245 246

    model = ParticipanteProyecto
247
    template_name = 'participante-proyecto/confirm_delete.html'
248 249

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

252
    def test_func(self):
253
        return self.es_coordinador(self.get_object().proyecto.id) or self.request.user.has_perm('indo.editar_proyecto')
254

255

256
class ProyectoCreateView(LoginRequiredMixin, ChecksMixin, CreateView):
257
    '''Crea una nueva solicitud de proyecto'''
258

259
    model = Proyecto
260
    template_name = 'proyecto/new.html'
261
    form_class = ProyectoForm
262 263

    def form_valid(self, form):
264 265
        # This method is called when valid form data has been POSTed,
        # to do custom logic on form data. It should return an HttpResponse.
266 267 268
        proyecto = form.save()
        self._guardar_coordinador(proyecto)
        self._registrar_creacion(proyecto)
269
        return redirect('proyecto_detail', proyecto.id)
270

271
    def get_form(self, form_class=None):
272
        '''
273
        Devuelve el formulario añadiendo automáticamente el campo Convocatoria,
274
        que es requerido, y el usuario, para comprobar si tiene los permisos necesarios.
275
        '''
276
        form = super(ProyectoCreateView, self).get_form(form_class)
277
        form.instance.user = self.request.user
278 279 280
        form.instance.convocatoria = Convocatoria(date.today().year)
        return form

281
    def _guardar_coordinador(self, proyecto):
282
        pp = ParticipanteProyecto(
283
            proyecto=proyecto, tipo_participacion=TipoParticipacion(nombre='coordinador'), usuario=self.request.user
284
        )
285
        pp.save()
286 287

    def _registrar_creacion(self, proyecto):
288 289
        evento = Evento.objects.get(nombre='creacion_solicitud')
        registro = Registro(descripcion='Creación inicial de la solicitud', evento=evento, proyecto=proyecto)
290 291
        registro.save()

292 293 294 295 296
    def test_func(self):
        # TODO: Comprobar usuario para Proyectos de titulación y POU.
        # TODO: Comprobar fecha
        return self.es_pas_o_pdi()

297

298
class ProyectoAnularView(LoginRequiredMixin, ChecksMixin, RedirectView):
299
    '''
300
    Cambia el estado de una solicitud de proyecto a Anulada.
301
    '''
302 303 304 305

    model = Proyecto

    def get_redirect_url(self, *args, **kwargs):
306
        return reverse_lazy('mis_proyectos', kwargs={'anyo': date.today().year})
307 308

    def post(self, request, *args, **kwargs):
309 310
        proyecto = Proyecto.objects.get(pk=kwargs.get('pk'))
        proyecto.estado = 'ANULADO'
311 312
        proyecto.save()

313
        messages.success(request, _('Su solicitud de proyecto ha sido anulada.'))
314 315 316
        return super().post(request, *args, **kwargs)

    def test_func(self):
317
        return self.es_coordinador(self.kwargs['pk'])
318 319


320
class ProyectoDetailView(LoginRequiredMixin, ChecksMixin, DetailView):
321
    '''Muestra una solicitud de proyecto.'''
322

323
    model = Proyecto
324
    template_name = 'proyecto/detail.html'
325 326 327 328

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

329 330
        pp_coordinador = self.object.get_participante_or_none('coordinador')
        context['pp_coordinador'] = pp_coordinador
331

332 333
        pp_coordinador_2 = self.object.get_participante_or_none('coordinador_2')
        context['pp_coordinador_2'] = pp_coordinador_2
334

335
        participantes = (
336 337
            self.object.participantes.filter(tipo_participacion='participante')
            .order_by('usuario__first_name', 'usuario__last_name')
338 339
            .all()
        )
340
        context['participantes'] = participantes
341 342

        invitados = (
343 344
            self.object.participantes.filter(tipo_participacion__in=['invitado', 'invitacion_rehusada'])
            .order_by('tipo_participacion', 'usuario__first_name', 'usuario__last_name')
345 346
            .all()
        )
347
        context['invitados'] = invitados
348

349
        context['campos'] = json.loads(self.object.programa.campos)
350

351
        context['permitir_edicion'] = (
352
            self.es_coordinador(self.object.id) and self.object.en_borrador()
353
        ) or self.request.user.has_perm('indo.editar_proyecto')
354

355
        context['es_coordinador'] = self.es_coordinador(self.object.id) and self.object.en_borrador()
356

357
        return context
358

359
    def test_func(self):
360
        proyecto_id = self.kwargs['pk']
361
        return self.esta_vinculado_o_es_decano_o_es_coordinador(proyecto_id)
362 363


364
class ProyectoListView(LoginRequiredMixin, PermissionRequiredMixin, SingleTableView):
365
    '''Muestra una tabla de todos los proyectos presentados en una convocatoria.'''
366

367 368
    permission_required = 'indo.listar_proyectos'
    permission_denied_message = _('Sólo los gestores pueden acceder a esta página.')
369
    table_class = ProyectosTable
370
    template_name = 'gestion/proyecto/tabla.html'
371 372 373

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
374
        context['anyo'] = self.kwargs['anyo']
375 376 377 378
        return context

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


385
class ProyectoPresentarView(LoginRequiredMixin, ChecksMixin, RedirectView):
386
    '''Presenta una solicitud de proyecto.
387

388
    El proyecto pasa de estado «Borrador» a estado «Solicitado».
389
    Se envían correos a los agentes involucrados.
390
    '''
391

392
    def get_redirect_url(self, *args, **kwargs):
393
        return reverse_lazy('proyecto_detail', args=[kwargs.get('pk')])
394 395

    def post(self, request, *args, **kwargs):
396
        proyecto_id = kwargs.get('pk')
397 398 399
        proyecto = Proyecto.objects.get(pk=proyecto_id)

        # TODO ¿Chequear el estado actual del proyecto?
400

401
        num_equipos = self.request.user.get_num_equipos(proyecto.convocatoria_id)
402
        num_max_equipos = proyecto.convocatoria.num_max_equipos
403 404 405 406
        if num_equipos >= num_max_equipos:
            messages.error(
                request,
                _(
407
                    f'''No puede presentar esta solicitud porque ya forma parte
408 409
                del número máximo de equipos de trabajo permitido ({num_max_equipos}).
                Para poder presentar esta solicitud de proyecto, antes debería renunciar
410
                a participar en algún otro proyecto.'''
411 412 413 414
                ),
            )
            return super().post(request, *args, **kwargs)

415
        if request.user.get_colectivo_principal() == 'ADS' and proyecto.ayuda != 0:
416 417
            messages.error(
                request,
418
                _('Los profesores de los centros adscritos no pueden coordinar ' 'proyectos con financiación.'),
419
            )
420 421
            return super().post(request, *args, **kwargs)

422
        if proyecto.ayuda > proyecto.programa.max_ayuda:
423 424 425
            messages.error(
                request,
                _(
426 427
                    f'La ayuda solicitada ({proyecto.ayuda} €) excede el máximo '
                    f'permitido para este programa ({proyecto.programa.max_ayuda} €).'
428 429 430 431 432
                ),
            )
            return super().post(request, *args, **kwargs)

        if not proyecto.tiene_invitados():
433
            messages.error(request, _('La solicitud debe incluir al menos un invitado a participar.'))
434
            return super().post(request, *args, **kwargs)
435

436
        self._enviar_invitaciones(request, proyecto)
437 438 439 440 441 442 443

        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)

444
        # TODO Enviar "resguardo" al solicitante. PDF?
445

446
        proyecto.estado = 'SOLICITADO'
447 448 449
        proyecto.save()

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

    def _enviar_invitaciones(self, request, proyecto):
454 455
        '''Envia un mensaje a cada uno de los invitados al proyecto.'''
        for invitado in proyecto.participantes.filter(tipo_participacion='invitado'):
456
            send_templated_mail(
457
                template_name='invitacion',
458 459 460
                from_email=None,  # settings.DEFAULT_FROM_EMAIL
                recipient_list=[invitado.usuario.email],
                context={
461 462 463 464 465 466 467 468 469
                    'nombre_coordinador': request.user.get_full_name(),
                    'nombre_invitado': invitado.usuario.get_full_name(),
                    'sexo_invitado': invitado.usuario.sexo,
                    'titulo_proyecto': proyecto.titulo,
                    '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'
                    ),
                    'site_url': settings.SITE_URL,
470 471 472
                },
            )

473
    def _enviar_solicitudes_visto_bueno_centro(self, request, proyecto):
474
        '''Envia un mensaje al responsable del centro solicitando su visto bueno.'''
475

476 477 478 479
        try:
            validate_email(proyecto.centro.email_decano)
        except ValidationError:
            messages.warning(
480
                request, _('La dirección de correo electrónico del director o decano ' 'del centro no es válida.')
481 482 483
            )
            return

484
        send_templated_mail(
485
            template_name='solicitud_visto_bueno_centro',
486 487 488
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=[proyecto.centro.email_decano],
            context={
489 490 491 492 493 494 495 496 497
                'nombre_coordinador': request.user.get_full_name(),
                'nombre_decano': proyecto.centro.nombre_decano,
                'tratamiento_decano': proyecto.centro.tratamiento_decano,
                'titulo_proyecto': proyecto.titulo,
                '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'
                ),
                'site_url': settings.SITE_URL,
498 499
            },
        )
500

501
    def _is_email_valid(self, email):
502
        '''Validate email address'''
503 504 505 506 507 508 509 510

        try:
            validate_email(email)
        except ValidationError:
            return False
        return True

    def _enviar_solicitudes_visto_bueno_estudio(self, request, proyecto):
511
        '''Envia mensaje a los coordinadores del plan solicitando su visto bueno.'''
512 513

        email_coordinadores_estudio = [
514
            f'{p.email_coordinador}'
515 516 517 518 519
            for p in proyecto.estudio.planes.all()
            if self._is_email_valid(p.email_coordinador)
        ]

        send_templated_mail(
520
            template_name='solicitud_visto_bueno_estudio',
521 522 523
            from_email=None,  # settings.DEFAULT_FROM_EMAIL
            recipient_list=email_coordinadores_estudio,
            context={
524 525 526 527 528 529 530
                'nombre_coordinador': request.user.get_full_name(),
                'titulo_proyecto': proyecto.titulo,
                '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'
                ),
                'site_url': settings.SITE_URL,
531 532 533
            },
        )

534 535
    def test_func(self):
        # TODO: Comprobar fecha
536
        return self.es_coordinador(self.kwargs['pk'])
537

538 539

class ProyectoUpdateFieldView(LoginRequiredMixin, ChecksMixin, UpdateView):
540
    '''Actualiza un campo de una solicitud de proyecto.'''
541

542 543
    # TODO: Comprobar estado/fecha
    model = Proyecto
544
    template_name = 'proyecto/update.html'
545 546

    def get_form_class(self, **kwargs):
547 548 549 550 551 552
        campo = self.kwargs['campo']
        if campo in ('centro', 'codigo', 'convocatoria', 'estado', 'estudio', 'linea', 'programa'):
            raise Http404(_('No puede editar ese campo.'))

        if campo not in ('titulo', 'departamento', 'licencia', 'ayuda', 'visto_bueno_centro', 'visto_bueno_estudio'):
            formulario = modelform_factory(Proyecto, fields=(campo,), widgets={campo: SummernoteWidget()})
553 554

            def as_p(self):
555
                '''
556 557
                Return this form rendered as HTML <p>s,
                with the helptext over the textarea.
558
                '''
559
                return self._html_output(
560
                    normal_row='''<p%(html_class_attr)s>
561 562 563
                    %(label)s
                    %(help_text)s
                    %(field)s
564 565 566
                    </p>''',
                    error_row='%s',
                    row_ender='</p>',
567 568 569 570 571
                    help_text_html='<span class="helptext">%s</span>',
                    errors_on_separate_row=True,
                )

            formulario.as_p = as_p
572 573 574 575 576 577 578 579

            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,
580 581 582 583
                        tags=(bleach.sanitizer.ALLOWED_TAGS + get_config('ADDITIONAL_ALLOWED_TAGS')),
                        attributes=get_config('ALLOWED_ATTRIBUTES'),
                        styles=get_config('ALLOWED_STYLES'),
                        protocols=get_config('ALLOWED_PROTOCOLS'),
584 585 586 587 588 589 590
                        strip=True,
                    )
                )
                return cleaned_data

            formulario.clean = clean

591
            return formulario
592 593
        self.fields = (campo,)
        return super().get_form_class()
594

595
    def test_func(self):
596
        '''Devuelve si el usuario está autorizado a modificar este campo.'''
597 598

        return (
599 600 601 602
            self.es_coordinador(self.kwargs['pk'])
            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']))
            or self.request.user.has_perm('indo.editar_proyecto')
603
        )
604

605

606
class ProyectosUsuarioView(LoginRequiredMixin, TemplateView):
607
    '''Lista los proyectos a los que está vinculado el usuario actual.'''
608

609
    template_name = 'proyecto/mis-proyectos.html'
610 611

    def get_context_data(self, **kwargs):
612
        usuario = self.request.user
613
        anyo = self.kwargs['anyo']
614
        context = super().get_context_data(**kwargs)
615
        context['proyectos_coordinados'] = (
616
            Proyecto.objects.filter(
617
                convocatoria__id=anyo,
618
                participantes__usuario=usuario,
619
                participantes__tipo_participacion_id__in=['coordinador', 'coordinador_2'],
620
            )
621 622
            .exclude(estado='ANULADO')
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
623 624
            .all()
        )
625
        context['proyectos_participados'] = (
626
            Proyecto.objects.filter(
627
                convocatoria__id=anyo,
628
                participantes__usuario=usuario,
629
                participantes__tipo_participacion_id='participante',
630
            )
631
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
632 633
            .all()
        )
634
        context['proyectos_invitado'] = (
635
            Proyecto.objects.filter(
636
                convocatoria__id=anyo, participantes__usuario=usuario, participantes__tipo_participacion_id='invitado'
637
            )
638
            .order_by('programa__nombre_corto', 'linea__nombre', 'titulo')
639 640
            .all()
        )
641

642
        try:
643
            nip_usuario = int(usuario.username)
644
        except ValueError:
645
            nip_usuario = 0
646

647
        centros_dirigidos = Centro.objects.filter(nip_decano=nip_usuario).all()
648
        if centros_dirigidos:
649 650
            context['proyectos_centros_dirigidos'] = Proyecto.objects.filter(
                convocatoria_id=anyo, programa__requiere_visto_bueno_centro=True, centro__in=centros_dirigidos
651 652
            ).all()

653 654 655
        planes_coordinados = Plan.objects.filter(nip_coordinador=nip_usuario).all()
        if planes_coordinados:
            id_estudios_coordinados = set([p.estudio_id for p in planes_coordinados])
656
            context['proyectos_estudios_coordinados'] = Proyecto.objects.filter(
657 658 659 660 661
                convocatoria_id=anyo,
                programa__requiere_visto_bueno_estudio=True,
                estudio_id__in=id_estudios_coordinados,
            )

662
        return context