From f2744163d3697b4ec72cdb4ed249949609075404 Mon Sep 17 00:00:00 2001
From: Tanner Prestegard <tanner.prestegard@ligo.org>
Date: Mon, 2 Apr 2018 12:24:34 -0500
Subject: [PATCH] generalizing and abstracting several models in the events
 app, as well as fixing many field parameters

---
 gracedb/core/models.py                        |  168 +++
 gracedb/events/admin.py                       |    2 +-
 gracedb/events/api/views.py                   |   27 +-
 .../events/migrations/0018_update_models.py   |   92 ++
 gracedb/events/models.py                      | 1066 ++++++++---------
 gracedb/events/view_utils.py                  |    2 +-
 gracedb/events/views.py                       |   19 +-
 gracedb/templates/profile/createContact.html  |    2 +-
 8 files changed, 816 insertions(+), 562 deletions(-)
 create mode 100644 gracedb/core/models.py
 create mode 100644 gracedb/events/migrations/0018_update_models.py

diff --git a/gracedb/core/models.py b/gracedb/core/models.py
new file mode 100644
index 000000000..26df59fd4
--- /dev/null
+++ b/gracedb/core/models.py
@@ -0,0 +1,168 @@
+from django.contrib.auth import get_user_model
+from django.db import models, connection
+
+import re
+
+UserModel = get_user_model()
+
+
+class CleanSaveModel(models.Model):
+    """Abstract model which automatically runs full_clean() before saving"""
+
+    class Meta:
+        abstract = True
+
+    def save(self, *args, **kwargs):
+        self.full_clean()
+        super(CleanSaveModel, self).save(*args, **kwargs)
+
+
+class AutoIncrementModel(models.Model):
+    """
+    An abstract class used as a base for classes which need the
+    autoincrementing save method described below.
+
+    AUTO_FIELD: name of field which acts as an autoincrement field.
+    AUTO_FK:    name of ForeignKey to which the AUTO_FIELD is relative.
+    """
+    AUTO_FIELD = None
+    AUTO_FK = None
+
+    class Meta:
+        abstract = True
+
+    def save(self, *args, **kwargs):
+        """
+        This custom save method does a SELECT and INSERT in a single raw SQL
+        query in order to properly handle a quasi-autoincrementing field, which
+        is used to identify instances associated with a ForeignKey. With this
+        method, concurrency issues are handled by the database backend.
+        Ex: EventLog instances associated with an Event should be numbered 1-N.
+
+        This has been tested with the following classes:
+            EventLog, EMObservation, EMFootprint, EMBBEventLog, VOEvent
+
+        Thorough testing is needed to use this method for a new model. Note
+        that this method may not work properly for non-MySQL backends.
+
+        Requires AUTO_FIELD and AUTO_FK to be defined.
+        """
+
+        if connection.vendor != 'mysql':
+            raise DatabaseError(_('The custom AutoIncrementModel save method '
+                'is not compatible with non-MySQL backends'))
+
+        # Do normal save if this is not an insert (i.e., the instance has a
+        # primary key already).
+        meta = self.__class__._meta
+        pk_set = self._get_pk_val(meta) is not None
+        if pk_set:
+            super(AutoIncrementModel, self).save(*args, **kwargs)
+            return
+
+        # Otherwise, we'll generate some raw SQL to do the
+        # insert and auto-increment.
+
+        # Get model fields, except for primary key field.
+        fields = meta.local_concrete_fields
+        if not pk_set:
+            fields = [f for f in fields if not
+                isinstance(f, models.fields.AutoField)]
+
+        # Setup for generating base SQL query for doing an INSERT.
+        query = models.sql.InsertQuery(self.__class__._base_manager.model)
+        query.insert_values(fields, objs=[self])
+        compiler = query.get_compiler(using=self.__class__._base_manager.db)
+        compiler.return_id = meta.has_auto_field and not pk_set
+
+        fk_name = meta.get_field(self.AUTO_FK).column
+        with compiler.connection.cursor() as cursor:
+            # Get base SQL query as string.
+            for sql, params in compiler.as_sql():
+                # Modify SQL string to do an INSERT with SELECT.
+                # NOTE: it's unlikely that the following will generate
+                # a functional database query for non-MySQL backends.
+
+                # Replace VALUES (%s, %s, ..., %s) with
+                # SELECT %s, %s, ..., %s
+                sql = re.sub(r"VALUES \((.*)\)", r"SELECT \1", sql)
+
+                # Add table to SELECT from and ForeignKey id corresponding to
+                # our autoincrement field.
+                sql += " FROM `{tbl_name}` WHERE `{fk_name}`={fk_id}".format(
+                    tbl_name=meta.db_table,
+                    fk_name=fk_name,
+                    fk_id=getattr(self, fk_name)
+                    )
+
+                # Get index corresponding to AUTO_FIELD.
+                af_idx = [f.name for f in fields].index(self.AUTO_FIELD)
+                # Put this directly in the SQL; cursor.execute quotes it
+                # as a literal, which causes the SQL command to fail.
+                # We shouldn't have issues with SQL injection because
+                # AUTO_FIELD should never be a user-defined parameter.
+                del params[af_idx]
+                sql = re.sub(r"((%s, ){{{0}}})%s".format(af_idx),
+                    r"\1IFNULL(MAX({af}),0)+1", sql, 1).format(
+                    af=self.AUTO_FIELD)
+
+                # Execute SQL command.
+                cursor.execute(sql, params)
+
+            # Get primary key from database and set it in memory.
+            if compiler.connection.features.can_return_id_from_insert:
+                id = compiler.connection.ops.fetch_returned_insert_id(cursor)
+            else:
+                id = compiler.connection.ops.last_insert_id(cursor,
+                    meta.db_table, meta.pk.column)
+            self._set_pk_val(id)
+
+            # Refresh object in memory in order to get AUTO_FIELD value.
+            self.refresh_from_db()
+
+            # Prevents check for unique primary key - needed to prevent an
+            # IntegrityError when the object was just created and we try to
+            # update it while it's still in memory
+            self._state.adding = False
+
+
+class LogBase(models.Model):
+    """
+    Abstract base class for log message-type objects. Concrete derived
+    classes will probably want to add a ForeignKey to another model.
+
+    Used in events.EventLog, superevents.Log
+    """
+    created = models.DateTimeField(auto_now_add=True)
+    issuer = models.ForeignKey(UserModel, null=False)
+    filename = models.CharField(max_length=100, default="", blank=True)
+    file_version = models.IntegerField(null=True, default=None, blank=True)
+    comment = models.TextField(null=False)
+    N = models.IntegerField(null=False, editable=False)
+
+    class Meta:
+        abstract = True
+        ordering = ['-created', '-N']
+
+    def fileurl(self):
+        # Override this on derived classes
+        return NotImplemented
+
+    def hasImage(self):
+        # XXX hacky
+        IMAGE_EXT = ['png', 'gif', 'jpg']
+        return (self.filename and self.filename[-3:].lower() in IMAGE_EXT)
+
+
+class m2mThroughBase(models.Model):
+    """
+    Abstract base class which is useful for providing "through" access for a
+    many-to-many relationship and recording the relationship creator and
+    creation time.
+    """
+    creator = models.ForeignKey(UserModel, null=False, related_name=
+        '%(app_label)s_%(class)s_set')
+    created = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        abstract = True
diff --git a/gracedb/events/admin.py b/gracedb/events/admin.py
index 289e7112b..04ce6a67a 100644
--- a/gracedb/events/admin.py
+++ b/gracedb/events/admin.py
@@ -29,7 +29,7 @@ class LabellingAdmin(admin.ModelAdmin):
 
 class TagAdmin(admin.ModelAdmin):
     list_display = [ 'name', 'displayName' ]
-    exclude = [ 'eventlogs' ]
+    exclude = [ 'event_logs' ]
 
 admin.site.register(Event, EventAdmin)
 admin.site.register(EventLog, EventLogAdmin)
diff --git a/gracedb/events/api/views.py b/gracedb/events/api/views.py
index b917ede48..b4490bfc3 100644
--- a/gracedb/events/api/views.py
+++ b/gracedb/events/api/views.py
@@ -357,7 +357,7 @@ class TSVRenderer(BaseRenderer):
 
 class EventList(APIView):
     """
-    This resource represents the collection of  all candidate events in GraceDB.
+    This resource represents the collection of all candidate events in GraceDB.
 
     ### GET
     Retrieve events. You may use the following parameters:
@@ -1238,7 +1238,7 @@ class EventLogTagDetail(APIView):
                 tag.save()
 
             # Now add the log message to this tag.
-            tag.eventlogs.add(eventlog)
+            tag.event_logs.add(eventlog)
 
             # Create a log entry to document the tag creation.
             msg = "Tagged message %s: %s " % (eventlog.N, tagname)
@@ -1265,10 +1265,10 @@ class EventLogTagDetail(APIView):
 
         try:
             tag = eventlog.tags.filter(name=tagname)[0]
-            tag.eventlogs.remove(eventlog)
+            tag.event_logs.remove(eventlog)
 
             # Is the tag empty now?  If so we can delete it.
-            if not tag.eventlogs.all():
+            if not tag.event_logs.all():
                 tag.delete()
 
             # Create a log entry to document the tag creation.
@@ -1687,7 +1687,7 @@ class Files(APIView):
         if is_external(request.user):
             try:
                 tag = Tag.objects.get(name=settings.EXTERNAL_ACCESS_TAGNAME)
-                tag.eventlogs.add(logentry)
+                tag.event_logs.add(logentry)
             except:
                 # XXX probably should at least log a warning here.
                 pass
@@ -1899,3 +1899,20 @@ class OperatorSignoffList(APIView):
              }
         return Response(rv)
 
+#==================================================================
+# Superevent
+
+class SupereventList(APIView):
+    """Superevent list resource"""
+
+    def get(self, request):
+        pass
+
+    def post(self, request):
+        pass
+
+class SupereventDetail(APIView):
+    """Superevent detail resource"""
+
+    def get(self, request):
+        pass
diff --git a/gracedb/events/migrations/0018_update_models.py b/gracedb/events/migrations/0018_update_models.py
new file mode 100644
index 000000000..be875c9e2
--- /dev/null
+++ b/gracedb/events/migrations/0018_update_models.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.5 on 2018-04-06 19:27
+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 = [
+        ('events', '0017_rename_pipelines'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='emfootprint',
+            name='N',
+            field=models.IntegerField(editable=False),
+        ),
+        migrations.AlterField(
+            model_name='emobservation',
+            name='group',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events_emobservation_set', to='events.EMGroup'),
+        ),
+        migrations.AlterField(
+            model_name='emobservation',
+            name='submitter',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events_emobservation_set', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='eventlog',
+            name='N',
+            field=models.IntegerField(editable=False),
+        ),
+        migrations.AlterField(
+            model_name='eventlog',
+            name='file_version',
+            field=models.IntegerField(blank=True, default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name='eventlog',
+            name='filename',
+            field=models.CharField(blank=True, default=b'', max_length=100),
+        ),
+        migrations.AlterField(
+            model_name='eventlog',
+            name='tags',
+            field=models.ManyToManyField(related_name='event_logs', to='events.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='labelling',
+            name='creator',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events_labelling_set', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='signoff',
+            name='submitter',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events_signoff_set', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='tag',
+            name='displayName',
+            field=models.CharField(blank=True, max_length=200, null=True),
+        ),
+        migrations.AlterField(
+            model_name='voevent',
+            name='N',
+            field=models.IntegerField(editable=False),
+        ),
+        migrations.AlterField(
+            model_name='voevent',
+            name='file_version',
+            field=models.IntegerField(blank=True, default=None, null=True),
+        ),
+        migrations.AlterField(
+            model_name='voevent',
+            name='filename',
+            field=models.CharField(blank=True, default=b'', max_length=100),
+        ),
+        migrations.AlterField(
+            model_name='voevent',
+            name='issuer',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events_voevent_set', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='voevent',
+            name='ivorn',
+            field=models.CharField(blank=True, default=b'', max_length=200),
+        ),
+    ]
diff --git a/gracedb/events/models.py b/gracedb/events/models.py
index c7e12eae8..7e1f1962b 100644
--- a/gracedb/events/models.py
+++ b/gracedb/events/models.py
@@ -3,11 +3,14 @@ import numbers
 
 from django.db import models, IntegrityError
 from django.urls import reverse
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
 
 from model_utils.managers import InheritanceManager
 
-from django.contrib.auth.models import User as DjangoUser
+#from django.contrib.auth.models import User as DjangoUser
 #from django.contrib.auth.models import Group
+from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 
 from guardian.models import GroupObjectPermission
@@ -24,6 +27,8 @@ from glue.lal import LIGOTimeGPS
 
 import json, re
 
+from core.models import AutoIncrementModel, CleanSaveModel
+from core.models import LogBase, m2mThroughBase
 from core.time_utils import posixToGpsTime
 
 from django.conf import settings
@@ -34,6 +39,8 @@ from cStringIO import StringIO
 from hashlib import sha1
 import shutil
 
+UserModel = get_user_model()
+
 SERVER_TZ = pytz.timezone(settings.TIME_ZONE)
 
 # Let's say we start here on schema versions
@@ -79,8 +86,10 @@ class Search(models.Model):
 class Label(models.Model):
     name = models.CharField(max_length=20, unique=True)
     # XXX really, does this belong here? probably not.
-    defaultColor = models.CharField(max_length=20, unique=False, default="black")
+    defaultColor = models.CharField(max_length=20, unique=False,
+        default="black")
     description = models.TextField(blank=False)
+
     def __unicode__(self):
         return self.name
 
@@ -104,12 +113,17 @@ class Event(models.Model):
 #    )
     DEFAULT_EVENT_NEIGHBORHOOD = (-5,5)
 
-    submitter = models.ForeignKey(DjangoUser)
+    submitter = models.ForeignKey(UserModel)
     created = models.DateTimeField(auto_now_add=True)
     group = models.ForeignKey(Group)
     #uid = models.CharField(max_length=20, default="")  # XXX deprecated.  should be removed.
     #analysisType = models.CharField(max_length=20, choices=ANALYSIS_TYPE_CHOICES)
 
+    # Events aren't required to be part of a superevent. If the superevent is
+    # deleted, don't delete the event; just set this FK to null.
+    #superevent = models.ForeignKey('superevents.Superevent', null=True,
+    #    related_name='events', on_delete=models.SET_NULL)
+
     # Note: a default value is needed only during the schema migration
     # that creates this column. After that, we can safely remove it.
     # The presence or absence of the default value has no effect on the DB
@@ -294,7 +308,7 @@ class Event(models.Model):
         """
         Optionally override the delete method for Event models.
         By default, deleting an Event deletes corresponding subclasses
-        (GrbEvent, CoincInspiralEvent, etc.) and EventLogs, EMObserverations,
+        (GrbEvent, CoincInspiralEvent, etc.) and EventLogs, EMObservations,
         etc., but does not remove the data directory or the
         GroupObjectPermissions corresponding to the Event or its subclasses.
 
@@ -325,114 +339,20 @@ class Event(models.Model):
         # Call base class delete
         super(Event, self).delete(*args, **kwargs)
 
-class AutoIncrementModel(models.Model):
+class EventLog(CleanSaveModel, LogBase, AutoIncrementModel):
     """
-    An abstract class used as a base for classes which need the
-    autoincrementing save method described below.
+    Log message object attached to an Event. Uses the AutoIncrementModel
+    to handle log enumeration on a per-Event basis.
     """
+    AUTO_FIELD = 'N'
+    AUTO_FK = 'event'
 
-    class Meta:
-        abstract = True
-
-    def save(self, auto_field, auto_fk, *args, **kwargs):
-        """
-        This custom save method does a SELECT and INSERT in a single raw SQL
-        query in order to properly handle a quasi-autoincrementing field, which
-        is used to identify instances associated with a ForeignKey. With this
-        method, concurrency issues are handled by the database backend.
-        Ex: EventLog instances associated with an Event should be numbered 1-N.
-
-        This has been tested with the following classes:
-            EventLog, EMObservation, EMFootprint, EMBBEventLog
-
-        Thorough testing is needed to use this method for a new model. Note
-        that this method may not work properly for non-MySQL backends.
-
-        Arguments:
-            auto_field: name of field which acts as an autoincrement field.
-            auto_fk:    name of ForeignKey to which the auto_field is relative.
-        """
-
-        # Do normal save if this is not an insert (i.e., the instance has a
-        # primary key already).
-        meta = self.__class__._meta
-        pk_set = self._get_pk_val(meta) is not None
-        if pk_set:
-            super(AutoIncrementModel, self).save(*args, **kwargs)
-            return
-
-        # Otherwise, we'll generate some raw SQL to do the
-        # insert and auto-increment.
-
-        # Get model fields, except for primary key field.
-        fields = meta.local_concrete_fields
-        if not pk_set:
-            fields = [f for f in fields if not
-                isinstance(f, models.fields.AutoField)]
-
-        # Setup for generating base SQL query for doing an INSERT.
-        query = models.sql.InsertQuery(self.__class__._base_manager.model)
-        query.insert_values(fields, objs=[self])
-        compiler = query.get_compiler(using=self.__class__._base_manager.db)
-        compiler.return_id = meta.has_auto_field and not pk_set
-
-        fk_name = meta.get_field(auto_fk).column
-        with compiler.connection.cursor() as cursor:
-            # Get base SQL query as string.
-            for sql, params in compiler.as_sql():
-                # Modify SQL string to do an INSERT with SELECT.
-                # NOTE: it's unlikely that the following will generate
-                # a functional database query for non-MySQL backends.
-
-                # Replace VALUES (%s, %s, ..., %s) with
-                # SELECT %s, %s, ..., %s
-                sql = re.sub(r"VALUES \((.*)\)", r"SELECT \1", sql)
-
-                # Add table to SELECT from and ForeignKey id corresponding to
-                # our autoincrement field.
-                sql += " FROM `{tbl_name}` WHERE `{fk_name}`={fk_id}".format(
-                    tbl_name=meta.db_table,
-                    fk_name=fk_name,
-                    fk_id=getattr(self, fk_name)
-                    )
-
-                # Get index corresponding to auto_field.
-                af_idx = [f.name for f in fields].index(auto_field)
-                # Put this directly in the SQL; cursor.execute quotes it
-                # as a literal, which causes the SQL command to fail.
-                # We shouldn't have issues with SQL injection because
-                # auto_field should never be a user-defined parameter.
-                del params[af_idx]
-                sql = re.sub(r"((%s, ){{{0}}})%s".format(af_idx),
-                    r"\1IFNULL(MAX({af}),0)+1", sql, 1).format(af=auto_field)
-
-                # Execute SQL command.
-                cursor.execute(sql, params)
-
-            # Get primary key from database and set it in memory.
-            if compiler.connection.features.can_return_id_from_insert:
-                id = compiler.connection.ops.fetch_returned_insert_id(cursor)
-            else:
-                id = compiler.connection.ops.last_insert_id(cursor,
-                    meta.db_table, meta.pk.column)
-            self._set_pk_val(id)
-
-            # Refresh object in memory in order to get auto_field value.
-            self.refresh_from_db()
-            
-class EventLog(AutoIncrementModel):
-    class Meta:
-        ordering = ['-created','-N']
-        unique_together = ('event','N')
+    # Extra fields
     event = models.ForeignKey(Event, null=False)
-    tags = models.ManyToManyField('Tag', related_name='eventlogs')
-    created = models.DateTimeField(auto_now_add=True)
-    issuer = models.ForeignKey(DjangoUser)
-    filename = models.CharField(max_length=100, default="")
-    comment = models.TextField(null=False)
-    #XXX Does this need to be indexed for better performance?
-    N = models.IntegerField(null=False)
-    file_version = models.IntegerField(null=True)
+    tags = models.ManyToManyField('Tag', related_name='event_logs')
+
+    class Meta(LogBase.Meta):
+        unique_together = (('event', 'N'),)
 
     def fileurl(self):
         if self.filename:
@@ -444,18 +364,6 @@ class EventLog(AutoIncrementModel):
         else:
             return None
 
-    def hasImage(self):
-        # XXX hacky
-        return self.filename and self.filename[-3:].lower() in ['png','gif','jpg']
-
-    def save(self, *args, **kwargs):
-        # XXX filename must not be 'None' because null=False for the filename
-        # field above.
-        self.filename = self.filename or ""
-
-        # Set up to call save method of the base class (AutoIncrementModel)
-        kwargs.update({'auto_field': 'N', 'auto_fk': 'event'})
-        super(EventLog, self).save(*args, **kwargs)
 
 class EMGroup(models.Model):
     name = models.CharField(max_length=50, unique=True)
@@ -465,107 +373,45 @@ class EMGroup(models.Model):
     # Let's leave this out for now. The submitter will be stored in 
     # the EMBB log record, and that should be enough for audit/blame
     # purposes.
-    #liasons = models.ManyToManyField(DjangoUser)
-
-    # XXX Characteristics needed to produce pointings?
+    #liasons = models.ManyToManyField(UserModel)
 
     def __unicode__(self):
         return self.name
 
-EMSPECTRUM = (
-('em.gamma',            'Gamma rays part of the spectrum'),
-('em.gamma.soft',       'Soft gamma ray (120 - 500 keV)'),
-('em.gamma.hard',       'Hard gamma ray (>500 keV)'),
-('em.X-ray',            'X-ray part of the spectrum'),
-('em.X-ray.soft',       'Soft X-ray (0.12 - 2 keV)'),
-('em.X-ray.medium',     'Medium X-ray (2 - 12 keV)'),
-('em.X-ray.hard',       'Hard X-ray (12 - 120 keV)'),
-('em.UV',               'Ultraviolet part of the spectrum'),
-('em.UV.10-50nm',       'Ultraviolet between 10 and 50 nm'),
-('em.UV.50-100nm',      'Ultraviolet between 50 and 100 nm'),
-('em.UV.100-200nm',     'Ultraviolet between 100 and 200 nm'),
-('em.UV.200-300nm',     'Ultraviolet between 200 and 300 nm'),
-('em.UV.FUV',           'Far-Infrared, 30-100 microns'),
-('em.opt',              'Optical part of the spectrum'),
-('em.opt.U',            'Optical band between 300 and 400 nm'),
-('em.opt.B',            'Optical band between 400 and 500 nm'),
-('em.opt.V',            'Optical band between 500 and 600 nm'),
-('em.opt.R',            'Optical band between 600 and 750 nm'),
-('em.opt.I',            'Optical band between 750 and 1000 nm'),
-('em.IR',               'Infrared part of the spectrum'),
-('em.IR.NIR',           'Near-Infrared, 1-5 microns'),
-('em.IR.J',             'Infrared between 1.0 and 1.5 micron'),
-('em.IR.H',             'Infrared between 1.5 and 2 micron'),
-('em.IR.K',             'Infrared between 2 and 3 micron'),
-('em.IR.MIR',           'Medium-Infrared, 5-30 microns'),
-('em.IR.3-4um',         'Infrared between 3 and 4 micron'),
-('em.IR.4-8um',         'Infrared between 4 and 8 micron'),
-('em.IR.8-15um',        'Infrared between 8 and 15 micron'),
-('em.IR.15-30um',       'Infrared between 15 and 30 micron'),
-('em.IR.30-60um',       'Infrared between 30 and 60 micron'),
-('em.IR.60-100um',      'Infrared between 60 and 100 micron'),
-('em.IR.FIR',           'Far-Infrared, 30-100 microns'),
-('em.mm',               'Millimetric part of the spectrum'),
-('em.mm.1500-3000GHz',  'Millimetric between 1500 and 3000 GHz'),
-('em.mm.750-1500GHz',   'Millimetric between 750 and 1500 GHz'),
-('em.mm.400-750GHz',    'Millimetric between 400 and 750 GHz'),
-('em.mm.200-400GHz',    'Millimetric between 200 and 400 GHz'),
-('em.mm.100-200GHz',    'Millimetric between 100 and 200 GHz'),
-('em.mm.50-100GHz',     'Millimetric between 50 and 100 GHz'),
-('em.mm.30-50GHz',      'Millimetric between 30 and 50 GHz'),
-('em.radio',            'Radio part of the spectrum'),
-('em.radio.12-30GHz',   'Radio between 12 and 30 GHz'),
-('em.radio.6-12GHz',    'Radio between 6 and 12 GHz'),
-('em.radio.3-6GHz',     'Radio between 3 and 6 GHz'),
-('em.radio.1500-3000MHz','Radio between 1500 and 3000 MHz'),
-('em.radio.750-1500MHz','Radio between 750 and 1500 MHz'),
-('em.radio.400-750MHz', 'Radio between 400 and 750 MHz'),
-('em.radio.200-400MHz', 'Radio between 200 and 400 MHz'),
-('em.radio.100-200MHz', 'Radio between 100 and 200 MHz'),
-('em.radio.20-100MHz',  'Radio between 20 and 100 MHz'),
-)
 
-class EMObservation(AutoIncrementModel):
-    """
-    EMObservation:  An observation record for EM followup.  
-    """
+class EMObservationBase(models.Model):
+    """Abstract base class for EM follow-up observation records"""
     class Meta:
+        abstract = True
         ordering = ['-created', '-N']
-        unique_together = ("event","N")
-
-    def __unicode__(self):
-        return "%s-%s-%d" % (self.event.graceid(), self.group.name, self.N)
 
     N = models.IntegerField(null=False)
     created = models.DateTimeField(auto_now_add=True)
-    event = models.ForeignKey(Event)
-    submitter  = models.ForeignKey(DjangoUser)  
+    submitter  = models.ForeignKey(UserModel, null=False,
+        related_name='%(app_label)s_%(class)s_set')
 
     # The MOU group responsible 
-    group = models.ForeignKey(EMGroup)       # from a table of facilities
+    group = models.ForeignKey(EMGroup, null=False,
+        related_name='%(app_label)s_%(class)s_set')
 
-    # The following fields should be calculated from the footprint info provided
-    # by the user. These fields are just for convenience and fast searching
+    # The following fields should be calculated from the footprint info
+    # provided by the user. These fields are just for convenience and
+    # fast searching
 
     # The center of the bounding box of the rectangular footprints ra,dec
     # in J2000 in decimal degrees
     ra         = models.FloatField(null=True)
-    dec        = models.FloatField(null=True)    
+    dec        = models.FloatField(null=True)
 
     # The width and height (RA range and Dec range) in decimal degrees 
     raWidth    = models.FloatField(null=True)
-    decWidth   = models.FloatField(null=True)    
+    decWidth   = models.FloatField(null=True)
 
     comment = models.TextField(blank=True)
 
-    def save(self, *args, **kwargs):
-        # Set up to call save method of the base class (AutoIncrementModel)
-        kwargs.update({'auto_field': 'N', 'auto_fk': 'event'})
-        super(EMObservation, self).save(*args, **kwargs)
-
-    def calculateCoveringRegion(self):
-        # How to access the related footprint objects?
-        footprints = self.emfootprint_set.all()
+    def calculateCoveringRegion(self, footprints=None):
+        # Implement most of the logic in the abstract class' method
+        # without needing to specify the footprints field
         if not footprints:
             return
 
@@ -597,313 +443,148 @@ class EMObservation(AutoIncrementModel):
         self.raWidth  = ramax-ramin
         self.decWidth = decmax-decmin
 
-class EMFootprint(AutoIncrementModel):
+
+class EMObservation(EMObservationBase, AutoIncrementModel):
+    """EMObservation class for events"""
+    AUTO_FIELD = 'N'
+    AUTO_FK = 'event'
+    event = models.ForeignKey(Event, null=False, on_delete=models.CASCADE)
+
+    class Meta(EMObservationBase.Meta):
+        unique_together = (('event', 'N'),)
+
+    def __unicode__(self):
+        return "{event_id} | {group} | {N}".format(
+            event_id=self.event.graceid(), group=self.group.name, N=self.N)
+
+    def __unicode__(self):
+        return "%s-%s-%d" % (self.event.graceid(), self.group.name, self.N)
+
+    def calculateCoveringRegion(self):
+        footprints = self.emfootprint_set.all()
+        super(EMObservation, self).calculateCoveringRegion(footprints)
+
+
+class EMFootprintBase(models.Model):
     """
-    A single footprint associated with an observation.
+    Abstract base class for EM footprints:
     Each EMObservation can have many footprints underneath.
 
     None of the fields are optional here.
     """
-    class Meta:
-        ordering = ['-N']
-        unique_together = ("observation","N")
-
-    N = models.IntegerField(null=False)
-
-    observation = models.ForeignKey(EMObservation, null = False)
+    N = models.IntegerField(null=False, editable=False)
 
     # The center of the rectangular footprint, right ascension and declination
     # in J2000 in decimal degrees
-    ra         = models.FloatField()
-    dec        = models.FloatField()    
+    ra         = models.FloatField(null=False, blank=False)
+    dec        = models.FloatField(null=False, blank=False)
 
     # The width and height (RA range and Dec range) in decimal degrees 
-    raWidth    = models.FloatField()
-    decWidth   = models.FloatField()    
+    raWidth    = models.FloatField(null=False, blank=False)
+    decWidth   = models.FloatField(null=False, blank=False)
 
     # The start time of the observation for this footprint
-    start_time = models.DateTimeField()
+    start_time = models.DateTimeField(null=False, blank=False)
 
     # The exposure time in seconds for this footprint
-    exposure_time = models.PositiveIntegerField()
-
-    def save(self, *args, **kwargs):
-        # Set up to call save method of the base class (AutoIncrementModel)
-        kwargs.update({'auto_field': 'N', 'auto_fk': 'observation'})
-        super(EMFootprint, self).save(*args, **kwargs)
-
-class EMBBEventLog(AutoIncrementModel):
-    """EMBB EventLog:  A multi-purpose annotation for EM followup.
-     
-    A rectangle on the sky, equatorially aligned,
-    that has or will be imaged that is related to an event"""
+    exposure_time = models.PositiveIntegerField(null=False, blank=False)
 
     class Meta:
-        ordering = ['-created', '-N']
-        unique_together = ("event","N")
-
-    def __unicode__(self):
-        return "%s-%s-%d" % (self.event.graceid(), self.group.name, self.N)
-
-    # A counter for Eels associated with a given event. This is 
-    # important for addressibility.
-    N = models.IntegerField(null=False)
-    
-    # The time at which this Eel was created. Important for event auditing.
-    created = models.DateTimeField(auto_now_add=True)
-
-    # The gracedb event that this Eel relates to
-    event = models.ForeignKey(Event)
-
-    # The responsible author of this communication
-    submitter  = models.ForeignKey(DjangoUser)  # from a table of people
-
-    # The MOU group responsible 
-    group = models.ForeignKey(EMGroup)       # from a table of facilities
-
-    # The instrument used or intended for the imaging implied by this footprint
-    instrument = models.CharField(max_length=200, blank=True)
-
-    # Facility-local identifier for this footprint
-    footprintID= models.TextField(blank=True)
-
-    # Now the global ID is a concatenation: facilityName#footprintID
-
-    # the EM waveband used for the imaging as below
-    waveband   = models.CharField(max_length=25, choices=EMSPECTRUM)
-
-    # The center of the bounding box of the rectangular footprints, right ascension and declination
-    # in J2000 in decimal degrees
-    ra         = models.FloatField(null=True)
-    dec        = models.FloatField(null=True)
-
-    # The width and height (RA range and Dec range) in decimal degrees of each image
-    raWidth    = models.FloatField(null=True)
-    decWidth   = models.FloatField(null=True)
+        abstract = True
+        ordering = ['-N']
 
-    # The GPS time of the middle of the bounding box of the imaging time
-    gpstime    = models.PositiveIntegerField(null=True)
 
-    # The duration of each image in seconds
-    duration   = models.PositiveIntegerField(null=True)
+class EMFootprint(EMFootprintBase, AutoIncrementModel):
+    """EMFootprint class for event EMObservations"""
+    # For AutoIncrementModel save
+    AUTO_FIELD = 'N'
+    AUTO_FK = 'observation'
+    observation = models.ForeignKey(EMObservation, null=False,
+        on_delete=models.CASCADE)
 
-    # The lists of RA and Dec of the centers of the images
-    raList         = models.TextField(blank=True)
-    decList        = models.TextField(blank=True)
+    class Meta(EMFootprintBase.Meta):
+        unique_together = (('observation', 'N'),)
 
-    # The width and height of each individual image
-    raWidthList    = models.TextField(blank=True)
-    decWidthList   = models.TextField(blank=True)
 
-    # The list of GPS times of the images
-    gpstimeList        = models.TextField(blank=True)
+class Labelling(m2mThroughBase):
+    """
+    Model which provides the "through" relationship between Events and Labels.
+    """
+    event = models.ForeignKey(Event)
+    label = models.ForeignKey(Label)
 
-    # The duration of each individual image
-    durationList   = models.TextField(blank=True)
+# XXX Deprecated?  Is this used *anywhere*?
+# Appears to only be used in models.py.  Here and Event class as approval_set
+class Approval(models.Model):
+    COLLABORATION_CHOICES = ( ('L','LIGO'), ('V','Virgo'), )
+    approver = models.ForeignKey(UserModel)
+    created = models.DateTimeField(auto_now_add=True)
+    approvedEvent = models.ForeignKey(Event, null=False)
+    approvingCollaboration = models.CharField(max_length=1, choices=COLLABORATION_CHOICES)
 
-    # Event Log status
-    EEL_STATUS_CHOICES = (('FO','FOOTPRINT'), ('SO','SOURCE'), ('CO','COMMENT'), ('CI','CIRCULAR'))
-    eel_status     = models.CharField(max_length=2, choices=EEL_STATUS_CHOICES)
+## Analysis Specific Attributes.
 
-    # Observation status. If OBSERVATION, then there is a good chance of good image
-    OBS_STATUS_CHOICES = (('NA', 'NOT APPLICABLE'), ('OB','OBSERVATION'), ('TE','TEST'), ('PR','PREDICTION'))
-    obs_status     = models.CharField(max_length=2, choices=OBS_STATUS_CHOICES)
+class GrbEvent(Event):
+    ivorn = models.CharField(max_length=200, null=True)
+    author_ivorn = models.CharField(max_length=200, null=True)
+    author_shortname = models.CharField(max_length=200, null=True)
+    observatory_location_id = models.CharField(max_length=200, null=True)
+    coord_system = models.CharField(max_length=200, null=True)
+    ra = models.FloatField(null=True)
+    dec = models.FloatField(null=True)
+    error_radius = models.FloatField(null=True)
+    how_description = models.CharField(max_length=200, null=True)
+    how_reference_url = models.URLField(null=True)
+    trigger_duration = models.FloatField(null=True)
+    t90 = models.FloatField(null=True)
+    designation = models.CharField(max_length=20, null=True)
+    redshift = models.FloatField(null=True)
+    trigger_id = models.CharField(max_length=25, null=True)
 
-    # This field is natural language for human
-    comment = models.TextField(blank=True)
+class CoincInspiralEvent(Event):
+    ifos             = models.CharField(max_length=20, default="")
+    end_time         = models.PositiveIntegerField(null=True)
+    end_time_ns      = models.PositiveIntegerField(null=True)
+    mass             = models.FloatField(null=True)
+    mchirp           = models.FloatField(null=True)
+    minimum_duration = models.FloatField(null=True)
+    snr              = models.FloatField(null=True)
+    false_alarm_rate = models.FloatField(null=True)
+    combined_far     = models.FloatField(null=True)
 
-    # This field is formal struct by a syntax TBD
-    # for example  {"phot.mag.limit": 22.3}
-    extra_info_dict = models.TextField(blank=True)
 
-    # Validates the input and builds  bounding box in RA/Dec/GPS
-    def validateMakeRects(self):
-        # get all the list based position and times and their widths
-        raRealList = []
-        rawRealList = []
-        # add a [ and ] to convert the input csv list to a json parsable text
+class MultiBurstEvent(Event):
+    ifos             = models.CharField(max_length=20, default="")
+    start_time       = models.PositiveIntegerField(null=True)
+    start_time_ns    = models.PositiveIntegerField(null=True)
+    duration         = models.FloatField(null=True)
+    peak_time        = models.PositiveIntegerField(null=True)
+    peak_time_ns     = models.PositiveIntegerField(null=True)
+    central_freq     = models.FloatField(null=True)
+    bandwidth        = models.FloatField(null=True)
+    amplitude        = models.FloatField(null=True)
+    snr              = models.FloatField(null=True)
+    confidence       = models.FloatField(null=True)
+    false_alarm_rate = models.FloatField(null=True)
+    ligo_axis_ra     = models.FloatField(null=True)
+    ligo_axis_dec    = models.FloatField(null=True)
+    ligo_angle       = models.FloatField(null=True)
+    ligo_angle_sig   = models.FloatField(null=True)
+    single_ifo_times = models.CharField(max_length=255, default="")
 
-        if self.raList:        raRealList = json.loads('['+self.raList+']')
-        if self.raWidthList:   rawRealList = json.loads('['+self.raWidthList+']')
-
-        if self.decList:       decRealList = json.loads('['+self.decList+']')
-        if self.decWidthList:  decwRealList = json.loads('['+self.decWidthList+']')
-
-        if self.gpstimeList:   gpstimeRealList = json.loads('['+self.gpstimeList+']')
-        if self.durationList:  durationRealList = json.loads('['+self.durationList+']')
-
-        # is there anything in the ra list? 
-        nList = len(raRealList)
-        if nList > 0: 
-            if decRealList and len(decRealList) != nList:
-                raise ValueError('RA and Dec lists are different lengths.')
-            if gpstimeRealList and len(gpstimeRealList) != nList:
-                raise ValueError('RA and GPS lists are different lengths.')
-
-        # is there anything in the raWidth list? 
-        mList = len(rawRealList)
-        if mList > 0:
-            if decwRealList and len(decwRealList) != mList:
-                raise ValueError('RAwidth and Decwidth lists are different lengths.')
-            if durationRealList and len(durationRealList) != mList:
-                raise ValueError('RAwidth and Duration lists are different lengths.')
-
-            # There can be 1 width for the whole list, or one for each ra/dec/gps 
-            if mList != 1 and mList != nList:
-                raise ValueError('Width and duration lists must be length 1 or same length as coordinate lists')
-        else:
-            mList = 0
-    
-        ramin = 360.0
-        ramax = 0.0
-        decmin = 90.0
-        decmax = -90.0
-        gpsmin = 100000000000
-        gpsmax = 0
-        for i in range(nList):
-            try:
-                ra = float(raRealList[i])
-            except:
-                raise ValueError('Cannot read RA list element %d of %s'%(i, self.raList))
-            try:
-                dec = float(decRealList[i])
-            except:
-                raise ValueError('Cannot read Dec list element %d of %s'%(i, self.decList))
-            try:
-                gps = int(gpstimeRealList[i])
-            except:
-                raise ValueError('Cannot read GPStime list element %d of %s'%(i, self.gpstimeList))
-
-            # the widths list can have 1 member to cover all, or one for each
-            if mList==1: j=0
-            else       : j=i
-    
-            try:
-                w = float(rawRealList[j])/2
-            except:
-                raise ValueError('Cannot read raWidth list element %d of %s'%(i, self.raWidthList))
-
-            # evaluate bounding box
-            if ra-w < ramin: ramin = ra-w
-            if ra+w > ramax: ramax = ra+w
-    
-            try:
-                w = float(decwRealList[j])/2
-            except:
-                raise ValueError('Cannot read raWidth list element %d of %s'%(i, self.decWidthList))
-
-            # evaluate bounding box
-            if dec-w < decmin: decmin = dec-w
-            if dec+w > decmax: decmax = dec+w
-    
-            try:
-                w = int(durationRealList[j])/2
-            except:
-                raise ValueError('Cannot read duration list element %d of %s'%(i, self.durationList))
-
-            # evaluate bounding box
-            if gps-w < gpsmin: gpsmin = gps-w
-            if gps+w > gpsmax: gpsmax = gps+w
-
-        # Make sure the min/max ra and dec are within bounds:
-        ramin  = max(0.0,   ramin)
-        ramax  = min(360.0, ramax)
-        decmin = max(-90.0, decmin)
-        decmax = min(90.0,  decmax)            
- 
-        if nList>0:
-            self.ra       = (ramin + ramax)/2
-            self.dec      = (decmin + decmax)/2
-            self.gpstime  = (gpsmin+gpsmax)/2
-        if mList>0:
-            self.raWidth  = ramax-ramin
-            self.decWidth = decmax-decmin
-            self.duration = gpsmax-gpsmin
-        return True
-
-    def save(self, *args, **kwargs):
-        # Set up for calling save method of the base class (AutoIncrementModel)
-        kwargs.update({'auto_field': 'N', 'auto_fk': 'event'})
-        super(EMBBEventLog, self).save(*args, **kwargs)
-
-class Labelling(models.Model):
-    event = models.ForeignKey(Event)
-    label = models.ForeignKey(Label)
-    creator = models.ForeignKey(DjangoUser)
-    created = models.DateTimeField(auto_now_add=True)
-
-# XXX Deprecated?  Is this used *anywhere*?
-# Appears to only be used in models.py.  Here and Event class as approval_set
-class Approval(models.Model):
-    COLLABORATION_CHOICES = ( ('L','LIGO'), ('V','Virgo'), )
-    approver = models.ForeignKey(DjangoUser)
-    created = models.DateTimeField(auto_now_add=True)
-    approvedEvent = models.ForeignKey(Event, null=False)
-    approvingCollaboration = models.CharField(max_length=1, choices=COLLABORATION_CHOICES)
-
-## Analysis Specific Attributes.
-
-class GrbEvent(Event):
-    ivorn = models.CharField(max_length=200, null=True)
-    author_ivorn = models.CharField(max_length=200, null=True)
-    author_shortname = models.CharField(max_length=200, null=True)
-    observatory_location_id = models.CharField(max_length=200, null=True)
-    coord_system = models.CharField(max_length=200, null=True)
-    ra = models.FloatField(null=True)
-    dec = models.FloatField(null=True)
-    error_radius = models.FloatField(null=True)
-    how_description = models.CharField(max_length=200, null=True)
-    how_reference_url = models.URLField(null=True)
-    trigger_duration = models.FloatField(null=True)
-    t90 = models.FloatField(null=True)
-    designation = models.CharField(max_length=20, null=True)
-    redshift = models.FloatField(null=True)
-    trigger_id = models.CharField(max_length=25, null=True)
-
-class CoincInspiralEvent(Event):
-    ifos             = models.CharField(max_length=20, default="")
-    end_time         = models.PositiveIntegerField(null=True)
-    end_time_ns      = models.PositiveIntegerField(null=True)
-    mass             = models.FloatField(null=True)
-    mchirp           = models.FloatField(null=True)
-    minimum_duration = models.FloatField(null=True)
-    snr              = models.FloatField(null=True)
-    false_alarm_rate = models.FloatField(null=True)
-    combined_far     = models.FloatField(null=True)
-
-
-class MultiBurstEvent(Event):
-    ifos             = models.CharField(max_length=20, default="")
-    start_time       = models.PositiveIntegerField(null=True)
-    start_time_ns    = models.PositiveIntegerField(null=True)
-    duration         = models.FloatField(null=True)
-    peak_time        = models.PositiveIntegerField(null=True)
-    peak_time_ns     = models.PositiveIntegerField(null=True)
-    central_freq     = models.FloatField(null=True)
-    bandwidth        = models.FloatField(null=True)
-    amplitude        = models.FloatField(null=True)
-    snr              = models.FloatField(null=True)
-    confidence       = models.FloatField(null=True)
-    false_alarm_rate = models.FloatField(null=True)
-    ligo_axis_ra     = models.FloatField(null=True)
-    ligo_axis_dec    = models.FloatField(null=True)
-    ligo_angle       = models.FloatField(null=True)
-    ligo_angle_sig   = models.FloatField(null=True)
-    single_ifo_times = models.CharField(max_length=255, default="")
-
-class LalInferenceBurstEvent(Event):
-    bci                 = models.FloatField(null=True)
-    quality_mean        = models.FloatField(null=True)
-    quality_median      = models.FloatField(null=True)
-    bsn                 = models.FloatField(null=True)
-    omicron_snr_network = models.FloatField(null=True)
-    omicron_snr_H1      = models.FloatField(null=True)
-    omicron_snr_L1      = models.FloatField(null=True)
-    omicron_snr_V1      = models.FloatField(null=True)
-    hrss_mean           = models.FloatField(null=True)
-    hrss_median         = models.FloatField(null=True)
-    frequency_mean      = models.FloatField(null=True)
-    frequency_median    = models.FloatField(null=True)
+class LalInferenceBurstEvent(Event):
+    bci                 = models.FloatField(null=True)
+    quality_mean        = models.FloatField(null=True)
+    quality_median      = models.FloatField(null=True)
+    bsn                 = models.FloatField(null=True)
+    omicron_snr_network = models.FloatField(null=True)
+    omicron_snr_H1      = models.FloatField(null=True)
+    omicron_snr_L1      = models.FloatField(null=True)
+    omicron_snr_V1      = models.FloatField(null=True)
+    hrss_mean           = models.FloatField(null=True)
+    hrss_median         = models.FloatField(null=True)
+    frequency_mean      = models.FloatField(null=True)
+    frequency_median    = models.FloatField(null=True)
 
 class SingleInspiral(models.Model):
     event             = models.ForeignKey(Event, null=False)
@@ -1114,43 +795,67 @@ class SimInspiralEvent(Event):
         cls._field_names = [ x.name for x in cls._meta.get_fields(include_parents=False) ]
         return cls._field_names
 
-## Tags (user-defined log message attributes)
-class Tag(models.Model):
-    """Tag Model"""
-    # XXX Does the tag need to have a submitter column?
-    # No, because creating a tag will generate a log message.
-    # For the same reason, a timstamp is not necessary.
-    name        = models.CharField(max_length=100)
-    displayName = models.CharField(max_length=200,null=True)
+# Tags (user-defined log message attributes)
+class Tag(CleanSaveModel):
+    """
+    Model for tags attached to EventLogs.
+
+    We don't use an explicit through model to track relationship creators and
+    time of relationship creation since we generally create a log message
+    whenever another log is tagged.  Not sure that it's good to make the
+    assumption that this will always be done.  But is it really important to
+    track those things?  Doesn't seem like it.
+    """
+    name = models.CharField(max_length=100, null=False, blank=False)
+    displayName = models.CharField(max_length=200, null=True, blank=True)
 
     def __unicode__(self):
-        if self.displayName:
-            return self.displayName
-        else:
-            return self.name
+        return self.displayName if self.displayName else self.name
 
-#     def getEvents(self):
-#         # XXX Any way of doing this with filters?
-#         # We would need to filter for a non-null intersection of the 
-#         # set of log messages in the event with the set of log 
-#         # messages in the tag.
-#         eventlist = [log.event for log in self.eventlogs.all()]
 
-class VOEvent(models.Model):
+class VOEventBase(models.Model):
+    """Abstract base model for VOEvents"""
+
     class Meta:
-        ordering = ['-created','-N']
-        unique_together = ("event","N")
-    # Now N will be the serial number.
-    event = models.ForeignKey(Event, null=False)
+        abstract = True
+        ordering = ['-created', '-N']
+
+    # VOEvent type choices
+    VOEVENT_TYPE_PRELIMINARY = 'PR'
+    VOEVENT_TYPE_INITIAL = 'IN'
+    VOEVENT_TYPE_UPDATE = 'UP'
+    VOEVENT_TYPE_RETRACTION = 'RE'
+    VOEVENT_TYPE_CHOICES = (
+        (VOEVENT_TYPE_PRELIMINARY, 'preliminary'),
+        (VOEVENT_TYPE_INITIAL, 'initial'),
+        (VOEVENT_TYPE_UPDATE, 'update'),
+        (VOEVENT_TYPE_RETRACTION, 'retraction'),
+    )
+
+    # Fields
     created = models.DateTimeField(auto_now_add=True)
-    issuer = models.ForeignKey(DjangoUser)
-    ivorn = models.CharField(max_length=200, default="")
-    filename = models.CharField(max_length=100, default="")
-    file_version = models.IntegerField(null=True)
-    N = models.IntegerField(null=False)
-    VOEVENT_TYPE_CHOICES = (('PR','preliminary'), ('IN','initial'), ('UP','update'), ('RE', 'retraction'),)
+    issuer = models.ForeignKey(UserModel, null=False,
+        related_name='%(app_label)s_%(class)s_set')
+    ivorn = models.CharField(max_length=200, default="", blank=True)
+    filename = models.CharField(max_length=100, default="", blank=True)
+    file_version = models.IntegerField(null=True, default=None, blank=True)
+    N = models.IntegerField(null=False, editable=False)
     voevent_type = models.CharField(max_length=2, choices=VOEVENT_TYPE_CHOICES)
 
+    def fileurl(self):
+        # Override this method on derived classes
+        return NotImplemented
+
+
+class VOEvent(VOEventBase, AutoIncrementModel):
+    """VOEvent class for events"""
+    AUTO_FIELD = 'N'
+    AUTO_FK = 'event'
+    event = models.ForeignKey(Event, null=False, on_delete=models.CASCADE)
+
+    class Meta(VOEventBase.Meta):
+        unique_together = (('event', 'N'),)
+
     def fileurl(self):
         if self.filename:
             actual_filename = self.filename
@@ -1161,46 +866,319 @@ class VOEvent(models.Model):
         else:
             return None
 
-    def save(self, *args, **kwargs):
-        success = False
-        # XXX filename must not be 'None' because null=False for the filename
-        # field above.
-        self.filename = self.filename or ""
-        attempts = 0
-        while (not success and attempts < 5):
-            attempts = attempts + 1
-            if not self.N:
-                if self.event.voevent_set.count():
-                    self.N = int(self.event.voevent_set.aggregate(models.Max('N'))['N__max']) + 1
-                else:
-                    self.N = 1
-            try:
-                super(VOEvent, self).save(*args, **kwargs)
-                success = True
-            except IntegrityError:
-                # IntegrityError means an attempt to insert a duplicate
-                # key or to violate a foreignkey constraint.
-                # We are under race conditions.  Let's try again.
-                pass
-
-        if not success:
-            # XXX Should this be a custom exception?  That way we could catch it
-            # 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') )
-SIGNOFF_TYPE_CHOICES = ( ('OP', 'operator'), ('ADV', 'advocate') )
-class Signoff(models.Model):
+
+class SignoffBase(models.Model):
+    """Abstract base model for operator and advocate signoffs"""
+
+    # Instrument choices
+    INSTRUMENT_H1 = 'H1'
+    INSTRUMENT_L1 = 'L1'
+    INSTRUMENT_V1 = 'V1'
+    INSTRUMENT_CHOICES = (
+        (INSTRUMENT_H1, 'LHO'),
+        (INSTRUMENT_L1, 'LLO'),
+        (INSTRUMENT_V1, 'Virgo'),
+    )
+
+    # Operator status choices
+    OPERATOR_STATUS_OK = 'OK'
+    OPERATOR_STATUS_NOTOK = 'NO'
+    OPERATOR_STATUS_CHOICES = (
+        (OPERATOR_STATUS_OK, 'OKAY'),
+        (OPERATOR_STATUS_NOTOK, 'NOT OKAY'),
+    )
+
+    # Signoff type choices
+    SIGNOFF_TYPE_OPERATOR = 'OP'
+    SIGNOFF_TYPE_ADVOCATE = 'ADV'
+    SIGNOFF_TYPE_CHOICES = (
+        (SIGNOFF_TYPE_OPERATOR, 'operator'),
+        (SIGNOFF_TYPE_ADVOCATE, 'advocate'),
+    )
+
+    # Field definitions
+    submitter = models.ForeignKey(UserModel, related_name=
+        '%(app_label)s_%(class)s_set')
+    comment = models.TextField(blank=True)
+    instrument = models.CharField(max_length=2, blank=True,
+        choices=INSTRUMENT_CHOICES)
+    status = models.CharField(max_length=2, blank=False,
+        choices=OPERATOR_STATUS_CHOICES)
+    signoff_type = models.CharField(max_length=3, blank=False,
+        choices=SIGNOFF_TYPE_CHOICES)
+
     class Meta:
-        unique_together = ("event","instrument")
-    submitter = models.ForeignKey(DjangoUser)
+        abstract = True
+
+    def clean(self, *args, **kwargs):
+        """Custom clean method for signoffs"""
+
+        # Make sure instrument is non-blank if this is an operator signoff
+        if (signoff_type == self.SIGNOFF_TYPE_OPERATOR and
+            not self.instrument):
+
+            raise ValidationError({'instrument':
+                _('Instrument must be specified for operator signoff')})
+
+        super(SignoffBase, self).clean(*args, **kwargs)
+
+
+class Signoff(SignoffBase):
+    """Class for Event signoffs"""
+
     event = models.ForeignKey(Event)
-    signoff_type = models.CharField(max_length=3, choices=SIGNOFF_TYPE_CHOICES, blank=False)
-    instrument = models.CharField(max_length=2, choices=INSTRUMENTS, blank=True)
-    status = models.CharField(max_length=2, choices=OPERATOR_STATUSES, blank=False)
-    comment = models.TextField(blank=True)
+
+    class Meta:
+        unique_together = ('event', 'instrument')
+
+    def __unicode__(self):
+        return "%s | %s | %s" % (self.event.graceid(), self.instrument,
+            self.status)
+
+EMSPECTRUM = (
+('em.gamma',            'Gamma rays part of the spectrum'),
+('em.gamma.soft',       'Soft gamma ray (120 - 500 keV)'),
+('em.gamma.hard',       'Hard gamma ray (>500 keV)'),
+('em.X-ray',            'X-ray part of the spectrum'),
+('em.X-ray.soft',       'Soft X-ray (0.12 - 2 keV)'),
+('em.X-ray.medium',     'Medium X-ray (2 - 12 keV)'),
+('em.X-ray.hard',       'Hard X-ray (12 - 120 keV)'),
+('em.UV',               'Ultraviolet part of the spectrum'),
+('em.UV.10-50nm',       'Ultraviolet between 10 and 50 nm'),
+('em.UV.50-100nm',      'Ultraviolet between 50 and 100 nm'),
+('em.UV.100-200nm',     'Ultraviolet between 100 and 200 nm'),
+('em.UV.200-300nm',     'Ultraviolet between 200 and 300 nm'),
+('em.UV.FUV',           'Far-Infrared, 30-100 microns'),
+('em.opt',              'Optical part of the spectrum'),
+('em.opt.U',            'Optical band between 300 and 400 nm'),
+('em.opt.B',            'Optical band between 400 and 500 nm'),
+('em.opt.V',            'Optical band between 500 and 600 nm'),
+('em.opt.R',            'Optical band between 600 and 750 nm'),
+('em.opt.I',            'Optical band between 750 and 1000 nm'),
+('em.IR',               'Infrared part of the spectrum'),
+('em.IR.NIR',           'Near-Infrared, 1-5 microns'),
+('em.IR.J',             'Infrared between 1.0 and 1.5 micron'),
+('em.IR.H',             'Infrared between 1.5 and 2 micron'),
+('em.IR.K',             'Infrared between 2 and 3 micron'),
+('em.IR.MIR',           'Medium-Infrared, 5-30 microns'),
+('em.IR.3-4um',         'Infrared between 3 and 4 micron'),
+('em.IR.4-8um',         'Infrared between 4 and 8 micron'),
+('em.IR.8-15um',        'Infrared between 8 and 15 micron'),
+('em.IR.15-30um',       'Infrared between 15 and 30 micron'),
+('em.IR.30-60um',       'Infrared between 30 and 60 micron'),
+('em.IR.60-100um',      'Infrared between 60 and 100 micron'),
+('em.IR.FIR',           'Far-Infrared, 30-100 microns'),
+('em.mm',               'Millimetric part of the spectrum'),
+('em.mm.1500-3000GHz',  'Millimetric between 1500 and 3000 GHz'),
+('em.mm.750-1500GHz',   'Millimetric between 750 and 1500 GHz'),
+('em.mm.400-750GHz',    'Millimetric between 400 and 750 GHz'),
+('em.mm.200-400GHz',    'Millimetric between 200 and 400 GHz'),
+('em.mm.100-200GHz',    'Millimetric between 100 and 200 GHz'),
+('em.mm.50-100GHz',     'Millimetric between 50 and 100 GHz'),
+('em.mm.30-50GHz',      'Millimetric between 30 and 50 GHz'),
+('em.radio',            'Radio part of the spectrum'),
+('em.radio.12-30GHz',   'Radio between 12 and 30 GHz'),
+('em.radio.6-12GHz',    'Radio between 6 and 12 GHz'),
+('em.radio.3-6GHz',     'Radio between 3 and 6 GHz'),
+('em.radio.1500-3000MHz','Radio between 1500 and 3000 MHz'),
+('em.radio.750-1500MHz','Radio between 750 and 1500 MHz'),
+('em.radio.400-750MHz', 'Radio between 400 and 750 MHz'),
+('em.radio.200-400MHz', 'Radio between 200 and 400 MHz'),
+('em.radio.100-200MHz', 'Radio between 100 and 200 MHz'),
+('em.radio.20-100MHz',  'Radio between 20 and 100 MHz'),
+)
+
+# TP (2 Apr 2018): pretty sure this class is deprecated - most recent
+# production use is T137114 = April 2015.
+class EMBBEventLog(AutoIncrementModel):
+    """EMBB EventLog:  A multi-purpose annotation for EM followup.
+
+    A rectangle on the sky, equatorially aligned,
+    that has or will be imaged that is related to an event"""
+
+    class Meta:
+        ordering = ['-created', '-N']
+        unique_together = ("event","N")
 
     def __unicode__(self):
-        return "%s | %s | %s" % (self.event.graceid(), self.instrument, self.status)
+        return "%s-%s-%d" % (self.event.graceid(), self.group.name, self.N)
+
+    # A counter for Eels associated with a given event. This is 
+    # important for addressibility.
+    N = models.IntegerField(null=False)
+
+    # The time at which this Eel was created. Important for event auditing.
+    created = models.DateTimeField(auto_now_add=True)
+
+    # The gracedb event that this Eel relates to
+    event = models.ForeignKey(Event)
+
+    # The responsible author of this communication
+    submitter  = models.ForeignKey(UserModel)  # from a table of people
+
+    # The MOU group responsible 
+    group = models.ForeignKey(EMGroup)       # from a table of facilities
+
+    # The instrument used or intended for the imaging implied by this footprint
+    instrument = models.CharField(max_length=200, blank=True)
+
+    # Facility-local identifier for this footprint
+    footprintID= models.TextField(blank=True)
+
+    # Now the global ID is a concatenation: facilityName#footprintID
+
+    # the EM waveband used for the imaging as below
+    waveband   = models.CharField(max_length=25, choices=EMSPECTRUM)
+
+    # The center of the bounding box of the rectangular footprints, right ascension and declination
+    # in J2000 in decimal degrees
+    ra         = models.FloatField(null=True)
+    dec        = models.FloatField(null=True)
+
+    # The width and height (RA range and Dec range) in decimal degrees of each image
+    raWidth    = models.FloatField(null=True)
+    decWidth   = models.FloatField(null=True)
+
+    # The GPS time of the middle of the bounding box of the imaging time
+    gpstime    = models.PositiveIntegerField(null=True)
+
+    # The duration of each image in seconds
+    duration   = models.PositiveIntegerField(null=True)
+
+    # The lists of RA and Dec of the centers of the images
+    raList         = models.TextField(blank=True)
+    decList        = models.TextField(blank=True)
+
+    # The width and height of each individual image
+    raWidthList    = models.TextField(blank=True)
+    decWidthList   = models.TextField(blank=True)
+
+    # The list of GPS times of the images
+    gpstimeList        = models.TextField(blank=True)
+
+    # The duration of each individual image
+    durationList   = models.TextField(blank=True)
+
+    # Event Log status
+    EEL_STATUS_CHOICES = (('FO','FOOTPRINT'), ('SO','SOURCE'), ('CO','COMMENT'), ('CI','CIRCULAR'))
+    eel_status     = models.CharField(max_length=2, choices=EEL_STATUS_CHOICES)
+
+    # Observation status. If OBSERVATION, then there is a good chance of good image
+    OBS_STATUS_CHOICES = (('NA', 'NOT APPLICABLE'), ('OB','OBSERVATION'), ('TE','TEST'), ('PR','PREDICTION'))
+    obs_status     = models.CharField(max_length=2, choices=OBS_STATUS_CHOICES)
+
+    # This field is natural language for human
+    comment = models.TextField(blank=True)
 
+    # This field is formal struct by a syntax TBD
+    # for example  {"phot.mag.limit": 22.3}
+    extra_info_dict = models.TextField(blank=True)
+
+    # For AutoIncrementModel save
+    AUTO_FIELD = 'N'
+    AUTO_FK = 'event'
+
+    # Validates the input and builds  bounding box in RA/Dec/GPS
+    def validateMakeRects(self):
+        # get all the list based position and times and their widths
+        raRealList = []
+        rawRealList = []
+        # add a [ and ] to convert the input csv list to a json parsable text
+
+        if self.raList:        raRealList = json.loads('['+self.raList+']')
+        if self.raWidthList:   rawRealList = json.loads('['+self.raWidthList+']')
+
+        if self.decList:       decRealList = json.loads('['+self.decList+']')
+        if self.decWidthList:  decwRealList = json.loads('['+self.decWidthList+']')
+
+        if self.gpstimeList:   gpstimeRealList = json.loads('['+self.gpstimeList+']')
+        if self.durationList:  durationRealList = json.loads('['+self.durationList+']')
+
+        # is there anything in the ra list? 
+        nList = len(raRealList)
+        if nList > 0: 
+            if decRealList and len(decRealList) != nList:
+                raise ValueError('RA and Dec lists are different lengths.')
+            if gpstimeRealList and len(gpstimeRealList) != nList:
+                raise ValueError('RA and GPS lists are different lengths.')
+
+        # is there anything in the raWidth list? 
+        mList = len(rawRealList)
+        if mList > 0:
+            if decwRealList and len(decwRealList) != mList:
+                raise ValueError('RAwidth and Decwidth lists are different lengths.')
+            if durationRealList and len(durationRealList) != mList:
+                raise ValueError('RAwidth and Duration lists are different lengths.')
+
+            # There can be 1 width for the whole list, or one for each ra/dec/gps 
+            if mList != 1 and mList != nList:
+                raise ValueError('Width and duration lists must be length 1 or same length as coordinate lists')
+        else:
+            mList = 0
+
+        ramin = 360.0
+        ramax = 0.0
+        decmin = 90.0
+        decmax = -90.0
+        gpsmin = 100000000000
+        gpsmax = 0
+        for i in range(nList):
+            try:
+                ra = float(raRealList[i])
+            except:
+                raise ValueError('Cannot read RA list element %d of %s'%(i, self.raList))
+            try:
+                dec = float(decRealList[i])
+            except:
+                raise ValueError('Cannot read Dec list element %d of %s'%(i, self.decList))
+            try:
+                gps = int(gpstimeRealList[i])
+            except:
+                raise ValueError('Cannot read GPStime list element %d of %s'%(i, self.gpstimeList))
+
+            # the widths list can have 1 member to cover all, or one for each
+            if mList==1: j=0
+            else       : j=i
+
+            try:
+                w = float(rawRealList[j])/2
+            except:
+                raise ValueError('Cannot read raWidth list element %d of %s'%(i, self.raWidthList))
+
+            # evaluate bounding box
+            if ra-w < ramin: ramin = ra-w
+            if ra+w > ramax: ramax = ra+w
+
+            try:
+                w = float(decwRealList[j])/2
+            except:
+                raise ValueError('Cannot read raWidth list element %d of %s'%(i, self.decWidthList))
+
+            # evaluate bounding box
+            if dec-w < decmin: decmin = dec-w
+            if dec+w > decmax: decmax = dec+w
+
+            try:
+                w = int(durationRealList[j])/2
+            except:
+                raise ValueError('Cannot read duration list element %d of %s'%(i, self.durationList))
+
+            # evaluate bounding box
+            if gps-w < gpsmin: gpsmin = gps-w
+            if gps+w > gpsmax: gpsmax = gps+w
+
+        # Make sure the min/max ra and dec are within bounds:
+        ramin  = max(0.0,   ramin)
+        ramax  = min(360.0, ramax)
+        decmin = max(-90.0, decmin)
+        decmax = min(90.0,  decmax)
+
+        if nList>0:
+            self.ra       = (ramin + ramax)/2
+            self.dec      = (decmin + decmax)/2
+            self.gpstime  = (gpsmin+gpsmax)/2
+        if mList>0:
+            self.raWidth  = ramax-ramin
+            self.decWidth = decmax-decmin
+            self.duration = gpsmax-gpsmin
+        return True
diff --git a/gracedb/events/view_utils.py b/gracedb/events/view_utils.py
index b4272ce32..3da787cb9 100644
--- a/gracedb/events/view_utils.py
+++ b/gracedb/events/view_utils.py
@@ -601,7 +601,7 @@ def signoffToDict(signoff):
         'instrument':   signoff.instrument,
         'status':       signoff.status,
         'comment':      signoff.comment,
-        'signoff_type': signoff.signoff_type
+        'signoff_type': signoff.signoff_type,
     } 
 
 #---------------------------------------------------------------------------------------
diff --git a/gracedb/events/views.py b/gracedb/events/views.py
index 4d7bfda5d..d9466015b 100644
--- a/gracedb/events/views.py
+++ b/gracedb/events/views.py
@@ -24,14 +24,13 @@ 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, delete_label
+from .view_logic import create_label
 from .view_utils import assembleLigoLw, get_file
 from .view_utils import flexigridResponse, jqgridResponse
 from .view_utils import get_recent_events_string
 from .view_utils import eventLogToDict
 from .view_utils import signoffToDict
 from .alert import issueAlertForUpdate, issueXMPPAlert
-from .models import SIGNOFF_TYPE_CHOICES
 
 # Set up logging
 import logging
@@ -242,7 +241,7 @@ def logentry(request, event, num=None):
                 tag = Tag(name=tagname, displayName=displayName)
                 tag.save()
 
-            tag.eventlogs.add(elog)
+            tag.event_logs.add(elog)
             # Create a log entry to document the tag creation.
             num = elog.N
             msg = "Tagged message %s: %s " % (num, tagname)
@@ -271,7 +270,7 @@ def logentry(request, event, num=None):
             # added the external access tagname somehow, and the following 
             # would result in an IntegrityError
             try:
-                tag.eventlogs.add(elog)
+                tag.event_logs.add(elog)
             except:
                 pass
 
@@ -770,7 +769,7 @@ def taglogentry(request, event, num, tagname):
             tag = db_tags[0]
 
         # Now add the log message to this tag.
-        tag.eventlogs.add(eventlog)
+        tag.event_logs.add(eventlog)
 
         # Create a log entry to document the tag creation.
         msg = "Tagged message %s: %s " % (num, tagname)
@@ -795,7 +794,7 @@ def taglogentry(request, event, num, tagname):
         tags = eventlog.tags.filter(name=tagname)
         if tags:
             tag = tags[0]
-            tag.eventlogs.remove(eventlog)
+            tag.event_logs.remove(eventlog)
         else:
             msg = "Attempted to delete tag that doesn't exist."
             return HttpResponseBadRequest(msg)
@@ -1072,7 +1071,7 @@ def modify_t90(request, event):
 
 
 def get_signoff_type(stype):
-    for t in SIGNOFF_TYPE_CHOICES:
+    for t in Signoff.SIGNOFF_TYPE_CHOICES:
         if stype in t:
             return t[0]
     return None
@@ -1163,7 +1162,7 @@ def modify_signoff(request, event):
         # Add a tag to the log message
         try:
             tag = Tag.objects.get(name='em_follow')
-            tag.eventlogs.add(logentry)
+            tag.event_logs.add(logentry)
         except:
             pass
 
@@ -1208,7 +1207,7 @@ def modify_signoff(request, event):
             # Add a tag to the log message
             try:
                 tag = Tag.objects.get(name='em_follow')
-                tag.eventlogs.add(logentry)
+                tag.event_logs.add(logentry)
             except:
                 pass
         else:
@@ -1239,7 +1238,7 @@ def modify_signoff(request, event):
             # Add a tag to the log message
             try:
                 tag = Tag.objects.get(name='em_follow')
-                tag.eventlogs.add(logentry)
+                tag.event_logs.add(logentry)
             except:
                 pass
 
diff --git a/gracedb/templates/profile/createContact.html b/gracedb/templates/profile/createContact.html
index 97433a073..7e47febfb 100644
--- a/gracedb/templates/profile/createContact.html
+++ b/gracedb/templates/profile/createContact.html
@@ -20,7 +20,7 @@
     <table>
         {{ form.as_table }}
     </table>
-    <input type="submit" value="Submit"/>
+    <input type="submit" value="Submit" />
 </form>
 
 {% endblock %}
-- 
GitLab