From 289f1e19673d4c5e1be38c584e4ebe854f8063cf Mon Sep 17 00:00:00 2001
From: Branson Stephens <branson.stephens@ligo.org>
Date: Fri, 7 Aug 2015 16:28:02 -0500
Subject: [PATCH] Changes for operator signoffs: - auth middleware to
 add/remove user to/from control room group based on IP. - new model for
 operator signoffs (event, instrument unique_together) - form to create, edit,
 and delete the operator signoff object - eventlog messages for changes to
 signoff objects. - OperatorSignoff serializer and LVAlert message - alert
 operator of outstanding events on landing page. - new labels and migration -
 Added logic to create/remove H1OK, H1NO, etc. labels. - Exposing the
 OperatorSignoff List resource through REST interface (GET only).

---
 gracedb/api.py                                |  37 ++++
 gracedb/forms.py                              |   8 +-
 gracedb/migrations/0004_operatorsignoff.py    |  27 +++
 gracedb/migrations/0005_auto_20150811_0929.py |  18 ++
 .../0006_add_operator_signoff_labels.py       |  27 +++
 gracedb/models.py                             |  14 ++
 gracedb/query.py                              |   2 +-
 gracedb/templatetags/timeutil.py              |  12 ++
 gracedb/urls.py                               |   1 +
 gracedb/urls_rest.py                          |   6 +
 gracedb/view_utils.py                         |   8 +
 gracedb/views.py                              | 202 +++++++++++++++++-
 ligoauth/middleware/auth.py                   |  32 +++
 settings/branson.py                           |  33 +--
 settings/default.py                           |   5 +
 static/css/style.css                          |  15 ++
 templates/gracedb/event_detail.html           |  53 +++++
 templates/gracedb/index.html                  |  15 ++
 18 files changed, 496 insertions(+), 19 deletions(-)
 create mode 100644 gracedb/migrations/0004_operatorsignoff.py
 create mode 100644 gracedb/migrations/0005_auto_20150811_0929.py
 create mode 100644 gracedb/migrations/0006_add_operator_signoff_labels.py

diff --git a/gracedb/api.py b/gracedb/api.py
index a4a7c5171..0e6987b2d 100644
--- a/gracedb/api.py
+++ b/gracedb/api.py
@@ -24,6 +24,7 @@ from view_utils import fix_old_creation_request
 from view_utils import eventToDict, eventLogToDict, labelToDict
 from view_utils import embbEventLogToDict, voeventToDict
 from view_utils import emObservationToDict, skymapViewerEMObservationToDict
+from view_utils import operatorSignoffToDict
 from view_utils import reverse
 
 from translator import handle_uploaded_data
@@ -1465,6 +1466,9 @@ class GracedbRoot(APIView):
         tag = tag.replace("0", "{n}")
         tag = tag.replace("tagname", "{tagname}")
 
+        operatorsignofflist = reverse("operatorsignoff-list", args=["G1200"], request=request)
+        operatorsignofflist = operatorsignofflist.replace("G1200", "{graceid}")
+
         # XXX Need a template for the tag list?
 
         templates = {
@@ -1478,6 +1482,7 @@ class GracedbRoot(APIView):
                 "filemeta-template" : filemeta,
                 "tag-template" : tag,
                 "taglist-template" : taglist,
+                "operatorsignoff-list-template": operatorsignofflist,
                 }
 
         return Response({
@@ -1835,3 +1840,35 @@ class VOEventDetail(APIView):
                     status=status.HTTP_404_NOT_FOUND)
         return Response(voeventToDict(voevent, request=request))
 
+#==================================================================
+# OperatorSignoff 
+
+class OperatorSignoffList(APIView):
+    """Operator Signoff List Resource
+
+    At present, this only supports GET
+    """
+    authentication_classes = (LigoAuthentication,)
+    permission_classes = (IsAuthenticated,IsAuthorizedForEvent,)
+    throttle_classes = (AnnotationThrottle,)
+
+    @event_and_auth_required
+    def get(self, request, event):
+        operator_signoff_set = event.operatorsignoff_set.all()
+        count = operator_signoff_set.count()
+
+        operator_signoff = [ operatorSignoffToDict(os)
+                for os in operator_signoff_set.iterator() ]
+
+        rv = {
+                'start': 0,
+                'numRows' : count,
+                'links' : {
+                    'self' : request.build_absolute_uri(),
+                    'first' : request.build_absolute_uri(),
+                    'last' : request.build_absolute_uri(),
+                    },
+                'operator_signoff' : operator_signoff,
+             }
+        return Response(rv)
+
diff --git a/gracedb/forms.py b/gracedb/forms.py
index cc4a1a2a9..6cbe08de5 100644
--- a/gracedb/forms.py
+++ b/gracedb/forms.py
@@ -3,9 +3,10 @@ from django import forms
 from django.utils.safestring import mark_safe
 from django.utils.html import escape
 from models import Event, Group, Label
-from models import Pipeline, Search
+from models import Pipeline, Search, OperatorSignoff
 from django.contrib.auth.models import User
 from django.core.exceptions import FieldError
+from django.forms import ModelForm
 
 from query import parseQuery
 from pyparsing import ParseException
@@ -79,3 +80,8 @@ class EventSearchForm(forms.Form):
 
     labels = forms.MultipleChoiceField(choices=labelChoices, required=False)
     get_neighbors = forms.BooleanField(required=False)
+
+class OperatorSignoffForm(ModelForm):
+    class Meta:
+        model = OperatorSignoff
+        fields = [ 'status', 'comment' ] 
diff --git a/gracedb/migrations/0004_operatorsignoff.py b/gracedb/migrations/0004_operatorsignoff.py
new file mode 100644
index 000000000..007ba6d82
--- /dev/null
+++ b/gracedb/migrations/0004_operatorsignoff.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('gracedb', '0003_grbevent_trigger_id'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='OperatorSignoff',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('instrument', models.CharField(max_length=2, choices=[(b'H1', b'LHO'), (b'L1', b'LLO'), (b'V1', b'Virgo')])),
+                ('status', models.CharField(max_length=2, choices=[(b'OK', b'OKAY'), (b'NO', b'NOT OKAY')])),
+                ('comment', models.TextField(blank=True)),
+                ('event', models.ForeignKey(to='gracedb.Event')),
+                ('submitter', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
diff --git a/gracedb/migrations/0005_auto_20150811_0929.py b/gracedb/migrations/0005_auto_20150811_0929.py
new file mode 100644
index 000000000..49cee514b
--- /dev/null
+++ b/gracedb/migrations/0005_auto_20150811_0929.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('gracedb', '0004_operatorsignoff'),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name='operatorsignoff',
+            unique_together=set([('event', 'instrument')]),
+        ),
+    ]
diff --git a/gracedb/migrations/0006_add_operator_signoff_labels.py b/gracedb/migrations/0006_add_operator_signoff_labels.py
new file mode 100644
index 000000000..8e7009612
--- /dev/null
+++ b/gracedb/migrations/0006_add_operator_signoff_labels.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+def add_labels(apps, schema_editor):
+    ifos = ['H1', 'L1']
+    labels = { 
+        'OPS': 'black',
+        'OK': 'green',
+        'NO': 'red',
+    }
+    Label = apps.get_model('gracedb', 'Label')
+    for ifo in ifos:
+        for name, color in labels.iteritems():
+            label_name = ifo + name
+            Label.objects.create(name=label_name, defaultColor=color)
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('gracedb', '0005_auto_20150811_0929'),
+    ]
+
+    operations = [
+        migrations.RunPython(add_labels)
+    ]
diff --git a/gracedb/models.py b/gracedb/models.py
index 032409f09..e9b47a6fb 100644
--- a/gracedb/models.py
+++ b/gracedb/models.py
@@ -1096,3 +1096,17 @@ class VOEvent(models.Model):
             # in the views that use it and give an informative error message.
             raise Exception("Too many attempts to save log message. Something is wrong.")
 
+INSTRUMENTS = ( ('H1', 'LHO'), ('L1', 'LLO'), ('V1', 'Virgo') )
+OPERATOR_STATUSES = ( ('OK', 'OKAY'), ('NO', 'NOT OKAY') )
+class OperatorSignoff(models.Model):
+    class Meta:
+        unique_together = ("event","instrument")
+    submitter = models.ForeignKey(DjangoUser)
+    event = models.ForeignKey(Event)
+    instrument = models.CharField(max_length=2, choices=INSTRUMENTS)
+    status = models.CharField(max_length=2, choices=OPERATOR_STATUSES, blank=False)
+    comment = models.TextField(blank=True)
+
+    def __unicode__(self):
+        return "%s | %s | %s" % (self.event.graceid(), self.instrument, self.status)
+
diff --git a/gracedb/query.py b/gracedb/query.py
index caceacda5..3015d13c4 100644
--- a/gracedb/query.py
+++ b/gracedb/query.py
@@ -181,7 +181,7 @@ createdQ = createdQ.setParseAction(maybeRange("created"))
 
 # Labels
 # XXX should we not get these from the DB?
-labelNames = ["DQV", "INJ", "LUMIN_NO", "LUMIN_GO", "SWIFT_NO", "SWIFT_GO", "EM_READY", "cWB_r","cWB_s"]
+labelNames = ["DQV", "INJ", "LUMIN_NO", "LUMIN_GO", "SWIFT_NO", "SWIFT_GO", "EM_READY", "cWB_r","cWB_s", "H1OPS", "L1OPS", "V1OPS"]
 label = Or([CaselessLiteral(n) for n in labelNames]).\
         setParseAction( lambda toks: Q(labels__name=toks[0]) )
 
diff --git a/gracedb/templatetags/timeutil.py b/gracedb/templatetags/timeutil.py
index 0e65e56ff..3eaee57bd 100644
--- a/gracedb/templatetags/timeutil.py
+++ b/gracedb/templatetags/timeutil.py
@@ -131,6 +131,18 @@ def utc(dt, format=FORMAT):
 def gpsdate(gpstime, format=FORMAT):
     return dateformat.format(gpsToUtc(gpstime), format)
 
+@register.filter
+def gpsdate_tz(gpstime, label="utc"):
+    format = FORMAT
+    dt = gpsToUtc(gpstime)
+    if label=='lho':
+        dt = dt.astimezone(LHO_TZ)
+    elif label=='llo':
+        dt = dt.astimezone(LLO_TZ)
+    elif label=='virgo':
+        dt = dt.astimezone(VIRGO_TZ)
+    return dateformat.format(dt, format)
+
 @register.filter
 def gpstime(dt):
     if not dt.tzinfo:
diff --git a/gracedb/urls.py b/gracedb/urls.py
index aedfb0bd6..1ddbe3e2c 100644
--- a/gracedb/urls.py
+++ b/gracedb/urls.py
@@ -19,6 +19,7 @@ urlpatterns = patterns('gracedb.views',
     url (r'^(?P<graceid>[GEHMT]\d+)$', 'view', name="view2"),
     url (r'^(?P<graceid>[GEHMT]\d+)/t90/$', 'modify_t90', name="modify_t90"),
     url (r'^(?P<graceid>[GEHMT]\d+)/perms/$', 'modify_permissions', name="modify_permissions"),
+    url (r'^(?P<graceid>[GEHMT]\d+)/signoff/$', 'modify_operator_signoff', name="modify_operator_signoff"),
     url (r'^(?P<graceid>[GEHMT]\d+)/files/$', 'file_list', name="file_list"),
     url (r'^(?P<graceid>[GEHMT]\d+)/files/(?P<filename>.*)$', download, name="file"),
     url (r'^(?P<graceid>[GEHMT]\d+)/log/(?P<num>([\d]*|preview))$', 'logentry', name="logentry"),
diff --git a/gracedb/urls_rest.py b/gracedb/urls_rest.py
index 2ed93e93b..048550373 100644
--- a/gracedb/urls_rest.py
+++ b/gracedb/urls_rest.py
@@ -21,6 +21,7 @@ from gracedb.api import EventPermissionList
 from gracedb.api import GroupEventPermissionList
 from gracedb.api import GroupEventPermissionDetail
 from gracedb.api import VOEventList, VOEventDetail
+from gracedb.api import OperatorSignoffList
 
 
 urlpatterns = patterns('gracedb.api',
@@ -104,6 +105,11 @@ urlpatterns = patterns('gracedb.api',
     url (r'^events/(?P<graceid>\w[\d]+)/neighbors/$',
         EventNeighbors.as_view(), name="neighbors"),
 
+    # Operator Signoff Resources
+    url (r'events/(?P<graceid>[GEHMT]\d+)/signoff/$',
+        OperatorSignoffList.as_view(), name='operatorsignoff-list'),
+
+
     # Performance stats
     url (r'^performance/$', 
         PerformanceInfo.as_view(), name='performance-info'),
diff --git a/gracedb/view_utils.py b/gracedb/view_utils.py
index bb2992583..70b3d5167 100644
--- a/gracedb/view_utils.py
+++ b/gracedb/view_utils.py
@@ -526,6 +526,14 @@ def singleInspiralToDict(single_inspiral):
             rv.update({ field_name: value })
     return rv
 
+def operatorSignoffToDict(operator_signoff):
+    return {
+        'submitter':  operator_signoff.submitter.username,
+        'instrument': operator_signoff.instrument,
+        'status':     operator_signoff.status,
+        'comment':    operator_signoff.comment,
+    } 
+
 #---------------------------------------------------------------------------------------
 #---------------------------------------------------------------------------------------
 # Miscellany
diff --git a/gracedb/views.py b/gracedb/views.py
index 3496f0441..63adf4e55 100644
--- a/gracedb/views.py
+++ b/gracedb/views.py
@@ -7,8 +7,8 @@ from django.core.urlresolvers import reverse
 from django.shortcuts import render_to_response
 
 from models import Event, Group, EventLog, Label, Tag, Pipeline, Search, GrbEvent
-from models import EMGroup
-from forms import CreateEventForm, EventSearchForm, SimpleSearchForm
+from models import EMGroup, OperatorSignoff
+from forms import CreateEventForm, EventSearchForm, SimpleSearchForm, OperatorSignoffForm
 
 from django.contrib.auth.models import User, Permission
 from django.contrib.auth.models import Group as AuthGroup
@@ -22,6 +22,7 @@ from view_logic import get_performance_info
 from view_logic import get_lvem_perm_status
 from view_logic import create_eel
 from view_logic import create_emobservation
+from view_logic import create_label
 from view_utils import assembleLigoLw, get_file
 from view_utils import flexigridResponse, jqgridResponse
 
@@ -63,9 +64,29 @@ def event_and_auth_required(view):
 
 def index(request):
 #   assert request.user
-    return render_to_response(
-            'gracedb/index.html',
-            {},
+    context = {}
+
+    signoff_authorized = False
+    signoff_instrument = None
+    # XXX Note that this may not be the best way to perform the authorization check.
+    # In particular, this assumes that the user can only be a member of one group 
+    # at a time. That should be the case, however, as the control room machines are 
+    # physically well separated and should have different IPs.
+    for group in request.user.groups.all():
+        if '_control_room' in group.name:
+            signoff_authorized = True
+            signoff_instrument = group.name[:2].upper()
+            break
+
+    context['signoff_authorized'] = signoff_authorized
+    context['signoff_instrument'] = signoff_instrument
+
+    if signoff_authorized:
+        label_name = signoff_instrument + 'OPS'
+        events = Event.objects.filter(labelling__label__name=label_name)
+        context['signoff_graceids'] = [e.graceid() for e in events]
+
+    return render_to_response('gracedb/index.html', context,
             context_instance=RequestContext(request))
 
 def navbar_only(request):
@@ -301,6 +322,30 @@ def view(request, event):
     if event.pipeline.name in settings.GRB_PIPELINES:
         context['can_modify_t90'] = request.user.has_perm('gracedb.t90_grbevent')
 
+    # Does the user have permission to sign off on the event?
+    signoff_authorized = False
+    # XXX Note that this may not be the best way to perform the authorization check.
+    # In particular, this assumes that the user can only be a member of one group 
+    # at a time. That should be the case, however, as the control room machines are 
+    # physically well separated and should have different IPs.
+    for group in request.user.groups.all():
+        if '_control_room' in group.name:
+            signoff_authorized = True
+            context['signoff_instrument'] = group.name[:2].upper()
+            context['signoff_form'] = OperatorSignoffForm()
+            instrument = group.name[:2].upper()
+            try:
+                context['signoff_object'] = OperatorSignoff.objects.get(event=event, instrument=instrument)
+            except:
+                context['signoff_object'] = None
+            label_name = instrument + 'OPS'
+            label_exists = label_name in [l.label.name for l in event.labelling_set.all()]
+
+            context['signoff_active'] = label_exists or context['signoff_object']
+
+            break
+    context['signoff_authorized'] = signoff_authorized
+
     # Choose your template according to the event's pipeline.
     templates = ['gracedb/event_detail.html',]
     if event.pipeline.name in settings.COINC_PIPELINES:
@@ -842,6 +887,153 @@ def modify_t90(request, event):
     # Finished. Redirect back to the event.
     return HttpResponseRedirect(reverse("view", args=[event.graceid()]))
 
+# XXX So this should probably be moved into view_logic anyway.
+from alert import issueXMPPAlert
+from view_utils import operatorSignoffToDict
+
+@event_and_auth_required
+def modify_operator_signoff(request, event):
+    # Get group_name and action from POST
+    if not request.method=='POST':
+        msg = 'create_operator_signoff only allows POST.'
+        return HttpResponseBadRequest(msg)
+
+    authorized = False
+    instrument = None
+    # XXX Note that this may not be the best way to perform the authorization check.
+    # In particular, this assumes that the user can only be a member of one group 
+    # at a time. That should be the case, however, as the control room machines are 
+    # physically well separated and should have different IPs.
+    for group in request.user.groups.all():
+        if '_control_room' in group.name:
+            authorized = True
+            instrument = group.name[:2].upper()
+            break
+
+    if not authorized:
+        msg = "You do not appear to be in one of the control rooms."
+        msg += " Therefore, you are not authorized to perform the requested action."
+        return HttpResponseForbidden(msg)      
+
+    action = request.POST.get('action', 'create')
+    label_name = instrument + 'OPS'
+
+    existing = OperatorSignoff.objects.filter(event=event, instrument=instrument)
+    if existing.count() and action=='create':
+        msg = 'Cannot create multiple signoffs for the same event/instrument.'
+        return HttpResponseBadRequest(msg) 
+
+    f = OperatorSignoffForm(request.POST)
+    status = None
+    comment = None
+    if f.is_valid():
+        status = f.cleaned_data['status']
+        comment = f.cleaned_data['comment']
+
+    if action=='create':
+        if status==None:
+            msg = "Please select a valid status."
+            return HttpResponseBadRequest(msg)
+
+        # Create the OperatorSignoff object.
+        os = OperatorSignoff.objects.create(submitter = request.user,
+            event = event, instrument = instrument, 
+            status = status, comment = comment)
+
+        # Remove the signoff label.
+        for l in event.labelling_set.all():
+            if l.label.name == label_name:
+                l.delete()
+
+        # Create a new label.
+        os_label_name = instrument + status
+        create_label(event, os_label_name, request.user, doAlert=False, doXMPP=False)
+
+        # Create a log message
+        msg = "Operator certified %s status as %s" % (instrument, status)
+        if comment:
+            msg += ': %s' % comment
+        logentry = EventLog.objects.create(event=event, issuer=request.user, comment=msg)
+
+        # XXX Ugh. Hardcoding tagname here.
+        # Add a tag to the log message
+        try:
+            tag = Tag.objects.get(name='em_follow')
+            tag.eventlogs.add(logentry)
+        except:
+            pass
+
+        # Issue an alert.
+        issueXMPPAlert(event, location='', alert_type="signoff", description=status, 
+            serialized_object = operatorSignoffToDict(os))
+
+    elif action=='edit':
+        # get the existing object
+        os = None
+        if existing.count():
+            os = existing[0]
+    
+        if not os:
+            msg = 'Could not find existing OperatorSignoff for this event/instrument.'
+            return HttpResponseBadRequest(msg)
+
+        # remove the existing label
+        os_label_name = os.instrument + os.status
+        for l in event.labelling_set.all():
+            if l.label.name == os_label_name:
+                l.delete()
+
+        delete = request.POST.get('delete', None)
+        if delete:
+            # delete the operator signoff object
+            os.delete()
+
+            # also restore the label
+            create_label(event, label_name, request.user)
+
+            # Create a log message
+            msg = "%s operator deleted signoff status" % instrument
+            logentry = EventLog.objects.create(event=event, issuer=request.user, comment=msg)
+
+            # XXX Ugh. Hardcoding tagname here.
+            # Add a tag to the log message
+            try:
+                tag = Tag.objects.get(name='em_follow')
+                tag.eventlogs.add(logentry)
+            except:
+                pass
+        else:
+            if status==None:
+                msg = "Please select a valid status."
+                return HttpResponseBadRequest(msg)
+            # update the values
+            os.status = status
+            os.comment = comment
+            os.save()
+            # Issue an alert.
+            issueXMPPAlert(event, location='', alert_type="signoff", description=status, 
+                serialized_object = operatorSignoffToDict(os))
+
+            # Create a new label.
+            os_label_name = instrument + status
+            create_label(event, os_label_name, request.user, doAlert=False, doXMPP=False)
+
+            # Create a log message
+            msg = "Operator updated %s status as %s" % (instrument, status)
+            if comment:
+                msg += ': %s' % comment
+            logentry = EventLog.objects.create(event=event, issuer=request.user, comment=msg)
+
+            # XXX Ugh. Hardcoding tagname here.
+            # Add a tag to the log message
+            try:
+                tag = Tag.objects.get(name='em_follow')
+                tag.eventlogs.add(logentry)
+            except:
+                pass
+
+    # Finished. Redirect back to the event.
+    return HttpResponseRedirect(reverse("view", args=[event.graceid()]))
 
 #------------------------------------------------------------------------------------------
 # Old Stuff
diff --git a/ligoauth/middleware/auth.py b/ligoauth/middleware/auth.py
index 444bec387..52702f43c 100644
--- a/ligoauth/middleware/auth.py
+++ b/ligoauth/middleware/auth.py
@@ -29,6 +29,14 @@ PUBLIC_URLS = [
     '/DiscoveryService/',
 ]
 
+def get_client_ip(request):
+    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+    if x_forwarded_for:
+        ip = x_forwarded_for.split(',')[0]
+    else:
+        ip = request.META.get('REMOTE_ADDR')
+    return ip
+
 def cert_dn_from_request(request):
     """Take a request, rummage through SSL_* headers, return the DN for the user."""
     certdn = request.META.get('SSL_CLIENT_S_DN')
@@ -143,6 +151,17 @@ class LigoAuthMiddleware:
 
         request.user = user
 
+        # If the user is connecting from one of the control rooms, add him/her to
+        # the appropriate control room group. But let's not do this if api
+        # is in the path.
+        if user and 'api' not in request.path_info:
+            user_ip = get_client_ip(request)
+            for ifo, ip in settings.CONTROL_ROOM_IPS.iteritems():
+                if ip == user_ip:
+                    group_name = ifo.lower() + '_control_room'
+                    group = Group.objects.get(name=group_name)
+                    group.user_set.add(user)                  
+
         # Check: Is the requested URL allowed for the PUBLIC?
         #if user is None:
         if user is None and request.path_info not in PUBLIC_URLS:
@@ -163,6 +182,19 @@ class LigoAuthMiddleware:
                     {'error': message}, status=403,
                     context_instance=RequestContext(request))
 
+    def process_response(self, request, response):
+        # If the user is connecting from one of the control rooms, remove him/her from
+        # the appropriate control room group
+        user = getattr(request, 'user', None)
+        if user:
+            user_ip = get_client_ip(request)
+            for ifo, ip in settings.CONTROL_ROOM_IPS.iteritems():
+                if ip == user_ip:
+                    group_name = ifo.lower() + '_control_room'
+                    group = Group.objects.get(name=group_name)
+                    group.user_set.remove(request.user)                  
+        return response
+
 class RemoteUserBackend(DefaultRemoteUserBackend):
     create_unknown_user = False
 
diff --git a/settings/branson.py b/settings/branson.py
index 63337b573..d225d95cb 100644
--- a/settings/branson.py
+++ b/settings/branson.py
@@ -2,7 +2,7 @@ CONFIG_NAME = "Branson"
 
 DEBUG = True
 TEMPLATE_DEBUG = DEBUG
-DEBUG_TOOLBAR_PATH_SETTINGS = False
+DEBUG_TOOLBAR_PATCH_SETTINGS = False
 
 DATABASES = {
     'default' : {
@@ -45,15 +45,19 @@ GRACEDB_DATA_DIR = "/home/branson/new_fake_data"
 EMAIL_HOST = 'localhost'
 
 ALERT_EMAIL_FROM = "Dev Alert <root@moe.phys.uwm.edu>"
-ALERT_EMAIL_TO = [
-    "Branson Stephens <branson@gravity.phys.uwm.edu>",
-    ]
-ALERT_EMAIL_BCC = ["branson@gravity.phys.uwm.edu"]
-
+#ALERT_EMAIL_TO = [
+#    "Branson Stephens <branson@gravity.phys.uwm.edu>",
+#    ]
+#ALERT_EMAIL_BCC = ["branson@gravity.phys.uwm.edu"]
+#
+#ALERT_TEST_EMAIL_FROM = "Dev Test Alert <root@moe.phys.uwm.edu>"
+#ALERT_TEST_EMAIL_TO = [
+#    "Branson Stephens <branson@gravity.phys.uwm.edu>",
+#    ]
+ALERT_EMAIL_TO = []
+ALERT_EMAIL_BCC = []
 ALERT_TEST_EMAIL_FROM = "Dev Test Alert <root@moe.phys.uwm.edu>"
-ALERT_TEST_EMAIL_TO = [
-    "Branson Stephens <branson@gravity.phys.uwm.edu>",
-    ]
+ALERT_TEST_EMAIL_TO = []
 ALERT_XMPP_SERVERS = ["lvalert-test.cgca.uwm.edu",]
 LVALERT_SEND_EXECUTABLE = '/home/branson/djangoenv/bin/lvalert_send'
 
@@ -103,7 +107,7 @@ MIDDLEWARE_CLASSES = [
     'django.contrib.messages.middleware.MessageMiddleware',
     'ligoauth.middleware.auth.LigoAuthMiddleware',
     #'debug_toolbar.middleware.DebugToolbarMiddleware',
-    #'debug_panel.middleware.DebugPanelMiddleware',
+    'debug_panel.middleware.DebugPanelMiddleware',
     'middleware.profiling.ProfileMiddleware',
 ]
 
@@ -118,14 +122,19 @@ INSTALLED_APPS = (
     'ligoauth',
     'rest_framework',
     'guardian',
-    #'debug_toolbar',
-    #'debug_panel',
+    'debug_toolbar',
+    'debug_panel',
 )
 
 INTERNAL_IPS = (
     '129.89.57.83',
 )
 
+CONTROL_ROOM_IPS = {
+    'H1': '108.45.69.217',
+#    'L1': '129.2.92.124',
+    'L1': '129.89.57.83',
+}
 
 # Settings for Logging.
 import logging
diff --git a/settings/default.py b/settings/default.py
index 46d4d5d24..7ef508c5a 100644
--- a/settings/default.py
+++ b/settings/default.py
@@ -345,6 +345,11 @@ SOUTH_TESTS_MIGRATE = False
 # passwords for LVEM scripted access expire after 365 days.
 PASSWORD_EXPIRATION_TIME = timedelta(days=365)
 
+CONTROL_ROOM_IPS = {
+    'H1': '129.89.57.83',
+    'L1': '129.2.92.124',
+}
+
 # XXX The following Log settings are for a performance metric.
 import logging
 LOG_ROOT = '/home/gracedb/logs'
diff --git a/static/css/style.css b/static/css/style.css
index bf36b7ac5..319108ec7 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -27,6 +27,21 @@ table.gstlalcbc th {padding:3px;border:none;vertical-align:bottom;}
     margin-right: 15px;
 }
 
+.signoff-area {
+    margin-top: 10px;
+    margin-bottom: 30px;
+    margin-left: 15px;
+    /*margin-right: 15px; */
+    max-width: 600px;
+    background-color: #ffe6e6; 
+    /* background-color: #fff0f0; */
+    padding: 4px;
+}
+
+.signoff-area th {
+    background-color: rgb(200, 200, 200);
+}
+
 table.figures tr.figrow  {text-align:center;} 
 table.figures {width:300px;height:270px;border:1px solid gray; display: inline-block; margin: 4px; overflow: hidden;}
 
diff --git a/templates/gracedb/event_detail.html b/templates/gracedb/event_detail.html
index 5727645c1..003559c38 100644
--- a/templates/gracedb/event_detail.html
+++ b/templates/gracedb/event_detail.html
@@ -62,6 +62,59 @@
 </div>
 {% endif %}
 
+{% if signoff_authorized and signoff_active %}
+    <div class="signoff-area">
+    <h2> {{signoff_instrument}} Operator Signoff </h2>
+    <p> You are seeing this section because you've connected from a machine that, 
+    according to our records, is in the {{signoff_instrument}} control room. 
+    {% if signoff_object %}
+    This event has already been signed off on.
+    Use the form below if you wish to edit or delete the record. </p>
+    <form action="{% url "modify_operator_signoff" object.graceid %}" method="post">
+        <table>
+        <tr><th><label for="id_status">Status:</label></th><td><select id="id_status" name="status">
+        <option value="">---------</option>
+        {% if signoff_object.status == "OK" %}
+        <option value="OK" selected="selected">OKAY</option>
+        <option value="NO">NOT OKAY</option>
+        {% else %}
+        <option value="OK">OKAY</option>
+        <option value="NO" selected="selected">NOT OKAY</option>
+        {% endif %}
+        </select></td></tr>
+        <tr><th><label for="id_comment">Comment:</label></th><td>
+        <textarea cols="40" id="id_comment" name="comment" rows="10"> {{signoff_object.comment}}
+        </textarea></td></tr>
+        <input type="hidden" name="action" value="edit">
+        <tr> <th> Delete </th> <td> <input type="checkbox" name="delete" value="true"> </td> </tr>
+        <tr> <td></td> <td> <input type="submit" value="Submit" class="searchButtonClass"> </td> </tr>
+        </table>
+    </form>
+
+    {% else %}
+    This event still requires operator signoff. </p> 
+    <p> Please answer the following (and optionally enter a comment): At the time of the 
+    {% if signoff_instrument == 'H1' %}
+        event ({{ object.gpstime|gpsdate_tz:"lho" }}),
+    {% elif signoff_instrument == 'L1' %}
+        event ({{ object.gpstime|gpsdate_tz:"llo" }}),
+    {% elif signoff_instrument == 'V1' %}
+        event ({{ object.gpstime|gpsdate_tz:"virgo" }}),
+    {% else %}
+        event,
+    {% endif %}
+    was the operating status of the detector basically okay, or not? </p>
+    <form action="{% url "modify_operator_signoff" object.graceid %}" method="post">
+        <table>
+        {{ signoff_form.as_table }}
+        <input type="hidden" name="action" value="create">
+        <tr> <td></td> <td> <input type="submit" value="Submit" class="searchButtonClass"> </td> </tr>
+        </table>
+    </form>
+    {% endif %}
+    </div>
+{% endif %}
+
 <div class="content-area">
 {% block basic_info %}
 <h2> Basic Info </h2>
diff --git a/templates/gracedb/index.html b/templates/gracedb/index.html
index 053de0e76..de236f43e 100644
--- a/templates/gracedb/index.html
+++ b/templates/gracedb/index.html
@@ -50,5 +50,20 @@ follow-ups. </p>
 
 </div>
 
+<!--XXX Infrastructure for human signoffs. Hopefully this will not be permanent. -->
+{% if signoff_authorized %}
+    {%if signoff_graceids|length %}
+    <div class="signoff-area">
+        <h2>Attention {{signoff_instrument}} Operator</h2>
+        <p> The following events require your sign-off: </p>
+        <ul>
+        {% for graceid in signoff_graceids %}
+            <li> <a href="{% url "view" graceid %}">{{ graceid }}</a></li> 
+        {% endfor %} 
+        </ul>
+        <p> Please click on the link(s) above and use the form at the top of the page. </p>            
+    </div>
+    {% endif %}
+{% endif %}
 
 {% endblock %}
-- 
GitLab