diff --git a/gracedb/api/v1/events/views.py b/gracedb/api/v1/events/views.py index e21c174efff482776b1d0fc301005f8a208080e4..e47d6e586d5c26c3a7b36c82411b45874c595f26 100644 --- a/gracedb/api/v1/events/views.py +++ b/gracedb/api/v1/events/views.py @@ -13,7 +13,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import IntegrityError from django.http import HttpResponse, HttpResponseForbidden, \ - HttpResponseNotFound, HttpResponseServerError + HttpResponseNotFound, HttpResponseServerError, HttpResponseBadRequest from django.http.request import QueryDict from django.utils.functional import wraps @@ -469,6 +469,15 @@ class EventList(InheritPermissionsAPIView): if not user_has_perm(request.user, "populate", pipeline): return HttpResponseForbidden("You don't have permission on this pipeline.") + # Get search since we won't block MDC event submissions even if the + # pipeline is disabled + search_name = request.data.get('search', None) + if not pipeline.enabled and search_name != 'MDC': + err_msg = ('The {0} pipeline has been temporarily disabled by ' + 'an EM advocate due to suspected misbehavior.').format( + pipeline.name) + return HttpResponseBadRequest(err_msg) + # The following looks a bit funny but it is actually necessary. The # django form expects a dict containing the POST data as the first # arg, and a dict containing the FILE data as the second. In the diff --git a/gracedb/core/tests/utils.py b/gracedb/core/tests/utils.py index dddbaff354887f86ecb0d3640bc7255a63913083..3705cb39719f90b1a45ff27f94c14f33a032c7f3 100644 --- a/gracedb/core/tests/utils.py +++ b/gracedb/core/tests/utils.py @@ -253,6 +253,10 @@ class SignoffGroupsAndUsersSetup(TestCase): content_type__app_label='superevents', codename='do_adv_signoff') g.permissions.add(*p) + p = Permission.objects.filter( + content_type__app_label='events', + codename='manage_pipeline') + g.permissions.add(*p) # Also add user to internal group internal_group.user_set.add(user) diff --git a/gracedb/events/migrations/0033_pipelinelog_and_pipeline_enabled.py b/gracedb/events/migrations/0033_pipelinelog_and_pipeline_enabled.py new file mode 100644 index 0000000000000000000000000000000000000000..4ce9a33f5b1aa09c8f31cdd4b104129edf067066 --- /dev/null +++ b/gracedb/events/migrations/0033_pipelinelog_and_pipeline_enabled.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2019-04-02 20:19 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('events', '0032_create_imbh_search'), + ] + + operations = [ + migrations.CreateModel( + name='PipelineLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('action', models.CharField(choices=[(b'D', b'disable'), (b'E', b'enable')], max_length=10)), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterModelOptions( + name='pipeline', + options={'permissions': (('manage_pipeline', 'Can enable or disable pipeline'),)}, + ), + migrations.AddField( + model_name='pipeline', + name='enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='pipelinelog', + name='pipeline', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.Pipeline'), + ), + ] diff --git a/gracedb/events/models.py b/gracedb/events/models.py index b2dee94d38a1ff43c331cf86a3da8f96908c7489..f38f86e0e3ac8896d48dbd2d8ae7eff0cb1f77f4 100644 --- a/gracedb/events/models.py +++ b/gracedb/events/models.py @@ -69,10 +69,30 @@ class Group(models.Model): class Pipeline(models.Model): name = models.CharField(max_length=100) + enabled = models.BooleanField(default=True) # XXX Need any additional fields? Like a librarian email? Or perhaps even fk? + class Meta: + permissions = ( + ('manage_pipeline', 'Can enable or disable pipeline'), + ) def __unicode__(self): return self.name + +class PipelineLog(models.Model): + PIPELINE_LOG_ACTION_DISABLE = 'D' + PIPELINE_LOG_ACTION_ENABLE = 'E' + PIPELINE_LOG_ACTION_CHOICES = ( + (PIPELINE_LOG_ACTION_DISABLE, 'disable'), + (PIPELINE_LOG_ACTION_ENABLE, 'enable'), + ) + creator = models.ForeignKey(UserModel) + pipeline = models.ForeignKey(Pipeline) + created = models.DateTimeField(auto_now_add=True) + action = models.CharField(max_length=10, + choices=PIPELINE_LOG_ACTION_CHOICES) + + class Search(models.Model): name = models.CharField(max_length=100) description = models.TextField(blank=True) diff --git a/gracedb/events/urls.py b/gracedb/events/urls.py index e0d4255322fb534ead4e6e1076fcc2b8a4ccab0a..6794322fd8ebab0655ba41ad6abe527569110817 100644 --- a/gracedb/events/urls.py +++ b/gracedb/events/urls.py @@ -45,6 +45,14 @@ urlpatterns = [ url(r'^(?P<graceid>[GEHMT]\d+)/emobservation/(?P<num>([\d]*|preview))$', views.emobservation_entry, name="emobservation_entry"), + # Manage pipelines + url(r'^pipelines/manage/$', views.PipelineManageView.as_view(), + name='manage-pipelines'), + url(r'^pipelines/(?P<pk>\d+)/enable/$', views.PipelineEnableView.as_view(), + name='enable-pipeline'), + url(r'^pipelines/(?P<pk>\d+)/disable/$', + views.PipelineDisableView.as_view(), name='disable-pipeline'), + # Legacy URLs ------------------------------------------------------------- # Event detail url(r'^view/(?P<graceid>[GEHMT]\d+)', views.view, name="legacyview"), diff --git a/gracedb/events/views.py b/gracedb/events/views.py index 472100517f7a8a757b2cfc97024eec651b71ac65..caced391c4448c1a5475eda50eafdbec9e3ee1cc 100644 --- a/gracedb/events/views.py +++ b/gracedb/events/views.py @@ -1,25 +1,31 @@ +from datetime import timedelta from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.http import HttpResponseRedirect, HttpResponseNotFound, HttpResponseBadRequest, Http404 from django.http import HttpResponseForbidden, HttpResponseServerError from django.template import RequestContext -from django.urls import reverse +from django.urls import reverse, reverse_lazy from django.shortcuts import render +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.generic import ListView +from django.views.generic.edit import UpdateView from core.file_utils import get_file_list from core.http import check_and_serve_file from .models import Event, Group, EventLog, Label, Tag, Pipeline, Search, GrbEvent -from .models import EMGroup, Signoff +from .models import EMGroup, Signoff, PipelineLog from .forms import CreateEventForm, SignoffForm +from django.contrib.auth.decorators import permission_required from django.contrib.auth.models import User, Permission from django.contrib.auth.models import Group as AuthGroup from django.contrib.contenttypes.models import ContentType from .permission_utils import filter_events_for_user, user_has_perm -from .permission_utils import internal_user_required, is_external, \ - check_external_file_access +from .permission_utils import is_external, check_external_file_access from guardian.models import GroupObjectPermission +from ligoauth.decorators import internal_user_required from .view_logic import _createEventFromForm from .view_logic import get_performance_info @@ -170,6 +176,15 @@ def _create(request): if not user_has_perm(request.user, "populate", pipeline): return HttpResponseForbidden("You do not have permission to submit events to this pipeline.") + # Get search since we won't block MDC event submissions even if + # the pipeline is disabled + search_name = request.POST.get('search', None) + if not pipeline.enabled and search_name != 'MDC': + err_msg = ('The {0} pipeline has been temporarily disabled by ' + 'an EM advocate due to suspected misbehavior.').format( + pipeline.name) + return HttpResponseBadRequest(err_msg) + form = CreateEventForm(request.POST, request.FILES) if form.is_valid(): # Alert is issued in this function @@ -958,3 +973,94 @@ def modify_signoff(request, event): # Finished. Redirect back to the event. return HttpResponseRedirect(reverse("view", args=[event.graceid])) + +# Managing pipeline submissions ----------------------------------------------- +PIPELINE_LIST = ['gstlal', 'pycbc', 'MBTAOnline', 'CWB', 'oLIB', 'spiir'] +PIPELINE_LOG_ACTION_DICT = dict(PipelineLog.PIPELINE_LOG_ACTION_CHOICES) + +@method_decorator(internal_user_required(raise_exception=True), + name='dispatch') +class PipelineManageView(ListView): + model = Pipeline + template_name = 'gracedb/manage_pipelines.html' + log_number = 10 + + def get_queryset(self): + qs = Pipeline.objects.filter(name__in=PIPELINE_LIST).order_by('name') + return qs + + def get_context_data(self, **kwargs): + context = super(PipelineManageView, self).get_context_data(**kwargs) + + # Get number of events created in a few different time periods for + # each pipeline and last submission time + n_events_dict = {} + submission_dict = {} + now = timezone.now() + dts = [now-timedelta(minutes=1), now-timedelta(minutes=10), + now-timedelta(minutes=60), now-timedelta(days=1)] + for p in self.object_list: + n_events_dict[p.name] = [p.event_set.filter(created__gt=dt) + .exclude(group__name='Test').exclude(search__name='MDC') + .count() for dt in dts] + last_event = p.event_set.exclude(group__name='Test').exclude( + search__name='MDC').order_by('-pk').first() + submission_dict[p.name] = getattr(last_event, 'created', None) + context['n_events_dict'] = n_events_dict + context['submission_dict'] = submission_dict + + # Get list of pipeline logs + context['logs'] = PipelineLog.objects.order_by('-created')[ + :self.log_number] + log_message_template = '{pipeline} {action}d by {user} at {dt}' + context['log_messages'] = [log_message_template.format( + pipeline=log.pipeline.name, user=log.creator.get_full_name(), + dt=log.created.strftime('%H:%M:%S %Z on %B %e, %Y'), + action=PIPELINE_LOG_ACTION_DICT[log.action]) + for log in context['logs']] + + # Determine whether user can enable/disable pipelines + context['user_can_manage'] = self.request.user.has_perm( + 'events.manage_pipeline') + + return context + + +@method_decorator(permission_required('events.manage_pipeline', + raise_exception=True), name='dispatch') +class PipelineEnableView(UpdateView): + """Enable a pipeline""" + success_url = reverse_lazy('manage-pipelines') + + def get_queryset(self): + qs = Pipeline.objects.filter(name__in=PIPELINE_LIST) + return qs + + def get(self, request, *args, **kwargs): + return self.post(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object.enabled: + self.object.enabled = True + self.object.save(update_fields=['enabled']) + PipelineLog.objects.create(creator=request.user, + pipeline=self.object, + action=PipelineLog.PIPELINE_LOG_ACTION_ENABLE) + return HttpResponseRedirect(self.get_success_url()) + + +@method_decorator(permission_required('events.manage_pipeline', + raise_exception=True), name='dispatch') +class PipelineDisableView(PipelineEnableView): + """Disable a pipeline""" + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + if self.object.enabled: + self.object.enabled = False + self.object.save(update_fields=['enabled']) + PipelineLog.objects.create(creator=request.user, + pipeline=self.object, + action=PipelineLog.PIPELINE_LOG_ACTION_DISABLE) + return HttpResponseRedirect(self.get_success_url()) diff --git a/gracedb/ligoauth/context_processors.py b/gracedb/ligoauth/context_processors.py index a44dc9cd61b8f1238a6a223bcebd514199ec5f56..98724f9e65edf1fef229fdde60f30412fbda0d99 100644 --- a/gracedb/ligoauth/context_processors.py +++ b/gracedb/ligoauth/context_processors.py @@ -4,11 +4,14 @@ def LigoAuthContext(request): user_is_internal = False user_is_lvem = False + user_is_advocate = False # user is an EM advocate if request.user: if request.user.groups.filter(name=settings.LVC_GROUP).exists(): user_is_internal = True if request.user.groups.filter(name=settings.LVEM_GROUP).exists(): user_is_lvem = True + if request.user.groups.filter(name=settings.EM_ADVOCATE_GROUP).exists(): + user_is_advocate = True return {'user': request.user, 'user_is_internal': user_is_internal, - 'user_is_lvem': user_is_lvem} + 'user_is_lvem': user_is_lvem, 'user_is_advocate': user_is_advocate} diff --git a/gracedb/migrations/auth/0023_add_manage_pipeline_permissions.py b/gracedb/migrations/auth/0023_add_manage_pipeline_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..fcdafaafaca36edb3fb4151617db5b0419e3e9f9 --- /dev/null +++ b/gracedb/migrations/auth/0023_add_manage_pipeline_permissions.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2019-04-02 21:21 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations +from django.contrib.auth.management import create_permissions + + +GROUP_NAME = settings.EM_ADVOCATE_GROUP +PERMISSION_KWARGS = { + 'content_type__app_label': 'events', + 'codename': 'manage_pipeline', +} + + + +# We have to run this to force the permissions to actually be created. +# Otherwise they are created by a post-migrate signal and it's not possible +# to run all of the migrations in a single command +def create_perms(apps, schema_editor): + for app_config in apps.get_app_configs(): + app_config.models_module = True + create_permissions(app_config, apps=apps, verbosity=0) + app_config.models_module = None + + +def add_perm(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + + # Get permission + perm = Permission.objects.get(**PERMISSION_KWARGS) + + # Get group + group = Group.objects.get(name=GROUP_NAME) + + # Add permission to group + perm.group_set.add(group) + + +def remove_perm(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + + # Get permission + perm = Permission.objects.get(**PERMISSION_KWARGS) + + # Get group + group = Group.objects.get(name=GROUP_NAME) + + # Remove permission from group + perm.group_set.remove(group) + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0022_populate_raven_users_group'), + ('events', '0033_pipelinelog_and_pipeline_enabled'), + ] + + operations = [ + migrations.RunPython(create_perms, migrations.RunPython.noop), + migrations.RunPython(add_perm, remove_perm), + ] diff --git a/gracedb/templates/gracedb/manage_pipelines.html b/gracedb/templates/gracedb/manage_pipelines.html new file mode 100644 index 0000000000000000000000000000000000000000..078b36ab3f4ab8994f18e0587862071f36ad1136 --- /dev/null +++ b/gracedb/templates/gracedb/manage_pipelines.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% load util_tags %} + +{% block title %}Manage pipelines{% endblock %} +{% block heading %}Enable/disable analysis pipelines{% endblock %} +{% block pageid %}pipelines{% endblock %} + +{% load static %} +{% block headcontents %} + <link rel="stylesheet" href="{% static "css/bootstrap_buttons.css" %}"></script> + {{ block.super }} +{% endblock %} + +{% block content %} + +<div style="padding-bottom: 20px;"> +<p>This page provides a brief summary of a pipeline's recent activity and provides the ability for EM advocates to disable (or enable) a pipeline's ability to submit events. +When a pipeline is disabled, non-Test, non-MDC events cannot be submitted for it. +This functionality is intended to mitigate the effects of a pipeline that is misbehaving and submitting erroneous event data.</p> +<p>All users: if you suspect any problems with a pipeline, you should contact one of the EM advocates who is currently on duty. The EM advocate schedule for O3 is <a href="https://ldas-jobs.ligo.caltech.edu/~emfollow/followup-advocate-guide/roster.html#shift-calendar">here</a>.</p> +<p>EM advocates: after you disable or enable a pipeline, you should contact the <a href="mailto:emfollow@ligo.org">low-latency group</a> to describe the action you have taken and the reasons for it.</p> +</div> + +{% if user_can_manage %} +<div align="center" style="padding-bottom: 20px;"> +<div style="background-color: red; color: white; display: inline-block; padding: 10px; border-radius: 5px;"> +<p align="center" style="font-weight: bold; font-size: 1.8rem; margin: 0;">WARNING: this page is live and any actions you take here will have consequences. Please be careful!</p> +</div> +</div> +{% endif %} + +<div style="padding-bottom: 40px;"> +<table class="bootstrap-dark" align="center"> +<caption>Pipeline status</caption> + <tr> + <th></th> + <th></th> + <th style="text-align: center;" colspan="5">Recent submissions</th> + <th></th> + </tr> + <tr> + <th>Pipeline</th> + <th class="center">Current status</th> + <th class="center">Time of last submission (UTC)</th> + <th class="center">Last minute</th> + <th class="center">Last 10 minutes</th> + <th class="center">Last hour</th> + <th class="center">Last day</th> + <th class="center">Action</th> + </tr> + {% for pipeline in pipeline_list %} + <tr> + <td>{{ pipeline.name }}</td> + <td class="center"> + {% if pipeline.enabled %} + <span style="color: green; font-weight: bold;">Enabled</span> + {% else %} + <span style="color: red; font-weight: bold;">Disabled</span> + {% endif %} + </td> + <td class="center">{{ submission_dict|get_item:pipeline.name }}</td> + {% with n_events_dict|get_item:pipeline.name as n_events_list %} + <td class="center">{{ n_events_list.0 }}</td> + <td class="center">{{ n_events_list.1 }}</td> + <td class="center">{{ n_events_list.2 }}</td> + <td class="center">{{ n_events_list.3 }}</td> + {% endwith %} + <td class="center"> + {% if pipeline.enabled %} + {% if user_can_manage %} + <a class="btn btn-danger" href="{% url "disable-pipeline" pipeline.pk %}">Disable</a> + {% else %} + <button class="btn btn-danger" disabled>Disable</button> + {% endif %} + {% else %} + {% if user_can_manage %} + <a class="btn btn-success" href="{% url "enable-pipeline" pipeline.pk %}">Enable</a> + {% else %} + <button class="btn btn-success" disabled>Enable</button> + {% endif %} + {% endif %} + </td> + </tr> + {% endfor %} +</table> +</div> + +{% if log_messages %} +<div style="padding-bottom: 10px;"> +<table class="bootstrap-dark" align="center"> +<caption>Recent activity</caption> +<tr> + <th>#</th> + <th>Comment</th> +{% for log in log_messages %} +<tr> + <td>{{ forloop.revcounter }} + <td>{{ log }}</td> +</tr> +{% endfor %} +</table> +</div> +{% endif %} +{% endblock %} diff --git a/gracedb/templates/navbar_frag.html b/gracedb/templates/navbar_frag.html index 578a3e61d04b277a9d57ceb7658c16f1b0fea524..562fcfaa33a6ca8a027b71d0c038e96e8363f6d9 100644 --- a/gracedb/templates/navbar_frag.html +++ b/gracedb/templates/navbar_frag.html @@ -13,6 +13,9 @@ {% elif user_is_lvem %} <li id="nav-password"><a href="{% url "manage-password" %}">Manage Password</a></li> {% endif %} + {% if user_is_advocate %} + <li id="nav-pipelines"><a href="{% url "manage-pipelines" %}">Pipelines</a></li> + {% endif %} <li id="nav-docs"><a href="{% url "home" %}documentation/">Documentation</a></li> {% if user %} {% if user.is_superuser %}