diff --git a/.gitattributes b/.gitattributes
index 4f4e4ad170fd7f74280945e9e41088a541d7d6ce..0149603c93452d7e707c57263ac58dcdd6a69f2f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4245,7 +4245,7 @@ SAS/LSMR/src/lsmr/lsmrapp/__init__.py -text
 SAS/LSMR/src/lsmr/lsmrapp/admin.py -text
 SAS/LSMR/src/lsmr/lsmrapp/apps.py -text
 SAS/LSMR/src/lsmr/lsmrapp/migrations/0001_initial.py -text
-SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180704_1623.py -text
+SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180705_0736.py -text
 SAS/LSMR/src/lsmr/lsmrapp/migrations/CMakeLists.txt -text
 SAS/LSMR/src/lsmr/lsmrapp/migrations/__init__.py -text
 SAS/LSMR/src/lsmr/lsmrapp/models.py -text
diff --git a/SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180704_1623.py b/SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180705_0736.py
similarity index 93%
rename from SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180704_1623.py
rename to SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180705_0736.py
index 2c04f26e11aa074d51131e618d85604b6310dc6d..e908c2de483ed36d8c70940fc8b34ad32facc6ad 100644
--- a/SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180704_1623.py
+++ b/SAS/LSMR/src/lsmr/lsmrapp/migrations/0002_auto_20180705_0736.py
@@ -1,4 +1,4 @@
-# Generated by Django 2.0.6 on 2018-07-04 16:23
+# Generated by Django 2.0.6 on 2018-07-05 07:36
 
 import django.contrib.postgres.fields
 import django.contrib.postgres.fields.jsonb
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
             name='GeneratorTemplate',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, null=True, size=8)),
+                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, size=8)),
                 ('creation', models.DateTimeField(auto_now_add=True)),
                 ('update', models.DateTimeField(auto_now=True)),
                 ('name', models.CharField(max_length=30)),
@@ -37,7 +37,7 @@ class Migration(migrations.Migration):
             name='RunTemplate',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, null=True, size=8)),
+                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, size=8)),
                 ('creation', models.DateTimeField(auto_now_add=True)),
                 ('update', models.DateTimeField(auto_now=True)),
                 ('name', models.CharField(max_length=30)),
@@ -64,14 +64,14 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('role', models.CharField(choices=[('CORRELATOR', 'correlator'), ('BEAMFORMER', 'beamformer'), ('INSPECTION_PLOTS', 'inspection plots'), ('CALIBRATOR', 'calibrator'), ('TARGET', 'target'), ('INPUT_OUTPUT', 'input, output')], max_length=30)),
                 ('datatype', models.CharField(choices=[('VISIBILITIES', 'visibilities'), ('TIME_SERIES', 'time series'), ('INSTRUMENT_MODEL', 'instrument model'), ('IMAGE', 'image'), ('QUALITY', 'quality')], max_length=30)),
-                ('dataformat', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('MEASUREMENTSET', 'MeasurementSet'), ('HDF5', 'HDF5')], max_length=30), blank=True, null=True, size=8)),
+                ('dataformat', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('MEASUREMENTSET', 'MeasurementSet'), ('HDF5', 'HDF5')], max_length=30), blank=True, size=8)),
             ],
         ),
         migrations.CreateModel(
             name='WorkRelationSelectionTemplate',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, null=True, size=8)),
+                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, size=8)),
                 ('creation', models.DateTimeField(auto_now_add=True)),
                 ('update', models.DateTimeField(auto_now=True)),
                 ('name', models.CharField(max_length=30)),
@@ -88,7 +88,7 @@ class Migration(migrations.Migration):
             name='WorkRequestTemplate',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, null=True, size=8)),
+                ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), blank=True, size=8)),
                 ('creation', models.DateTimeField(auto_now_add=True)),
                 ('update', models.DateTimeField(auto_now=True)),
                 ('name', models.CharField(max_length=30)),
diff --git a/SAS/LSMR/src/lsmr/lsmrapp/models.py b/SAS/LSMR/src/lsmr/lsmrapp/models.py
index 58cff4769455abeb99d784e78be8629939a6bb10..a7c28a13ef263cb12eb5a7d230bc8647d473d029 100644
--- a/SAS/LSMR/src/lsmr/lsmrapp/models.py
+++ b/SAS/LSMR/src/lsmr/lsmrapp/models.py
@@ -2,7 +2,7 @@
 This file contains the database models
 """
 
-from django.db.models import Model, CharField, DateTimeField, BooleanField, ForeignKey, CASCADE, TextField
+from django.db.models import Model, CharField, DateTimeField, BooleanField, ForeignKey, CASCADE, Field
 from django.contrib.postgres.fields import ArrayField, JSONField
 from django.contrib.postgres.indexes import GinIndex
 from enum import Enum
@@ -17,7 +17,7 @@ from enum import Enum
 class BasicCommon(Model):
     # todo: we cannot use foreign keys in the array here, so we have to keep the Tags table up to date by trigger or so.
     # todo: we could switch to a manytomany field instead?
-    tags = ArrayField(CharField(max_length=30), size=8, blank=True, null=True)
+    tags = ArrayField(CharField(max_length=30), size=8, blank=True)
     creation = DateTimeField(auto_now_add=True)
     update = DateTimeField(auto_now=True)
 
@@ -47,6 +47,7 @@ class Tags(Model):
     title = CharField(max_length=30)
     description = CharField(max_length=255)
 
+
 #
 # I/O
 #
@@ -75,13 +76,26 @@ class DataformatChoice(Enum):
     HDF5 = "HDF5"
 
 
+# todo: fix this!
+#class EnumField(Field):  # todo: Test if CharField works better e.g. for forms
+#    """
+#    Django does not support creating db enums, so we got to do that ourselves here
+#    """
+#    def __init__(self, *args, **kwargs):
+#        super(EnumField, self).__init__(*args, **kwargs)
+#
+#    def db_type(self, connection):
+#        return "enum(%s)" % ','.join("'%s'" % key for (key, _) in self.choices)
+# # todo: unfortunately this won't work for Postgres since a CREATE TYPE is required first...
+
+
 # concrete models
 
 class WorkIORoles(Model):
-    # todo: Choices does not seem to translate to a a Postgres ENUM type currently
+    # todo: Choices do not translate to a Postgres ENUM type currently, but are only enforced on the Django level.
     # todo: If we are not happy with this as is, I see two options:
     # todo: 1. Wait to see if they implement that EnumField in the meantime (https://code.djangoproject.com/ticket/24342)
-    # todo: 2. Do that in raw SQL outside Django
+    # todo: 2. Build this ourselves. I tried to achieve that with the EnumField above, but it won't be that easy, I guess
     role = CharField(
         max_length=30,
         choices=[(item.name, item.value) for item in RoleChoice]
@@ -93,7 +107,7 @@ class WorkIORoles(Model):
     dataformat = ArrayField(CharField(
         max_length=30,
         choices=[(item.name, item.value) for item in DataformatChoice]
-    ), size=8, blank=True, null=True)
+    ), size=8, blank=True)
     outputs = ForeignKey("WorkRequestTemplate", related_name='role_output', on_delete=CASCADE, null=True)
     inputs = ForeignKey("WorkRequestTemplate", related_name='role_input', on_delete=CASCADE, null=True)
 
diff --git a/SAS/LSMR/test/t_functional.py b/SAS/LSMR/test/t_functional.py
index 0711a18f38c6ae0e2723961a659c672be4ad075c..f32aaa8e446cdef58b520233495fc57d942878ad 100755
--- a/SAS/LSMR/test/t_functional.py
+++ b/SAS/LSMR/test/t_functional.py
@@ -19,6 +19,16 @@
 
 # $Id:  $
 
+
+# This functional test talks to the API like a regular user would.
+# It is supposed to cover all REST http methods for all ViewSets.
+# todo: For now I only covered one of the templates as they are mostly identical. I am still a bit under the impression
+# todo: ...that we re-test Django functionality that we can expect to just work with some of these tests. On the other
+# todo: ...hand a lot of these provide us a nice basis for differentiating out behavior in a controlled way.
+# todo: So if this is generally how we want to test, we should probably still cover the other templates.
+# todo: We should probably also fully test behavior wrt mandatory and nullable fields.
+
+
 import unittest
 import requests
 import json
@@ -134,7 +144,7 @@ class GeneratorTemplateTestCase(unittest.TestCase):
         self.assertEqual(r.status_code, 200)
         self.assertTrue("Generator Template List" in r.content.decode('utf8'))
 
-    def test_generator_template_GET_nonexistant_item_raises_error(self):
+    def test_generator_template_GET_nonexistant_raises_error(self):
         r = requests.get(BASE_URL + '/generator_template/1234321/')
         self.assertEqual(r.status_code, 404)
 
@@ -186,15 +196,160 @@ class RunTemplateTestCase(unittest.TestCase):
 
 
 class WorkRequestTemplateTestCase(unittest.TestCase):
-    # todo
-    pass
 
+    test_data_1 = {"name": "observation",
+                   "description": 'My one observation',
+                   "default_version": False,
+                   "version": 'v0.314159265359',
+                   "schema": {"mykey": "my value"},
+                   "tags": ["LSMR", "TESTING"],
+                   "validation_code_js": "???"}
+
+    # todo
 
-class WorkRelationTemplateTestCase(unittest.TestCase):
+class WorkRelationSelectionTemplateTestCase(unittest.TestCase):
     # todo
     pass
 
 
+class WorkIORolesTestCase(unittest.TestCase):
+
+    # test data
+    test_data_1 = {"role": "CALIBRATOR",
+                   "datatype": "INSTRUMENT_MODEL",
+                   "dataformat": ['HDF5'],
+                   "outputs": None,
+                   "inputs": None
+                   }
+
+    test_data_2 = {"role": "TARGET",
+                   "datatype": "VISIBILITIES",
+                   "dataformat": ['MEASUREMENTSET'],
+                   "outputs": None,
+                   "inputs": None
+                   }
+
+    test_patch = {"role": 'CORRELATOR',
+                  "dataformat": ['MEASUREMENTSET', "HDF5"]
+                  }
+
+    def test_work_io_roles_list_apiformat(self):
+        r = requests.get(BASE_URL + '/work_io_roles/?format=api')
+        self.assertEqual(r.status_code, 200)
+        self.assertTrue("Work Io Roles List" in r.content.decode('utf8'))
+
+    def test_work_io_roles_GET_nonexistant_raises_error(self):
+        r = requests.get(BASE_URL + '/work_io_roles/1234321/')
+        self.assertEqual(r.status_code, 404)
+
+    def test_work_io_roles_POST_and_GET(self):
+        # POST and GET a new item and assert correctness
+        url = POST_and_assert_correct(self, BASE_URL + '/work_io_roles/', self.test_data_1)
+        GET_and_assert_correct(self, url + '?format=json', self.test_data_1)
+
+    def test_work_io_roles_POST_invalid_role_raises_error(self):
+
+        # POST a new item with invalid choice
+        test_data_invalid_role = dict(self.test_data_1)
+        test_data_invalid_role['role'] = 'forbidden'
+        r = requests.post(BASE_URL + '/work_io_roles/', json=test_data_invalid_role)
+        self.assertEqual(r.status_code, 400)
+        r_json = json.loads(r.content.decode('utf-8'))
+        self.assertTrue('not a valid choice' in str(r_json['role']))
+
+    def test_work_io_roles_POST_invalid_datatype_raises_error(self):
+
+        # POST a new item with invalid choice
+        test_data_invalid = dict(self.test_data_1)
+        test_data_invalid['datatype'] = 'forbidden'
+        r = requests.post(BASE_URL + '/work_io_roles/', json=test_data_invalid)
+        self.assertEqual(r.status_code, 400)
+        r_json = json.loads(r.content.decode('utf-8'))
+        self.assertTrue('not a valid choice' in str(r_json['datatype']))
+
+    def test_work_io_roles_POST_invalid_dataformat_raises_error(self):
+
+        # POST a new item with invalid choice
+        test_data_invalid = dict(self.test_data_1)
+        test_data_invalid['dataformat'] = ['forbidden', "HDF5"]
+        r = requests.post(BASE_URL + '/work_io_roles/', json=test_data_invalid)
+        self.assertEqual(r.status_code, 400)
+        r_json = json.loads(r.content.decode('utf-8'))
+        self.assertTrue('not a valid choice' in str(r_json['dataformat']))
+
+    def test_work_io_roles_POST_nonexistant_inputs_raises_error(self):
+
+        # POST a new item with wrong reference
+        test_data_invalid = dict(self.test_data_1)
+        test_data_invalid['inputs'] = BASE_URL + "/work_request_template/6353748/"
+        r = requests.post(BASE_URL + '/work_io_roles/', json=test_data_invalid)
+        self.assertEqual(r.status_code, 400)
+        r_json = json.loads(r.content.decode('utf-8'))
+        self.assertTrue('Invalid hyperlink' in str(r_json['inputs']))
+
+    def test_work_io_roles_POST_existing_inputs_works(self):
+
+        # First POST a new item to reference
+        url = POST_and_assert_correct(self, BASE_URL + '/work_request_template/', WorkRequestTemplateTestCase.test_data_1)
+
+        # POST a new item with correct reference
+        test_data_valid = dict(self.test_data_1)
+        test_data_valid['inputs'] = url
+        POST_and_assert_correct(self, BASE_URL + '/work_io_roles/', test_data_valid)
+
+    def test_work_io_roles_POST_nonexistant_outputs_raises_error(self):
+
+        # POST a new item with wrong reference
+        test_data_invalid = dict(self.test_data_1)
+        test_data_invalid['outputs'] = BASE_URL + "/work_request_template/6353748/"
+        r = requests.post(BASE_URL + '/work_io_roles/', json=test_data_invalid)
+        self.assertEqual(r.status_code, 400)
+        r_json = json.loads(r.content.decode('utf-8'))
+        self.assertTrue('Invalid hyperlink' in str(r_json['outputs']))
+
+    def test_work_io_roles_POST_existing_outputs_works(self):
+
+        # First POST a new item to reference
+        url = POST_and_assert_correct(self, BASE_URL + '/work_request_template/', WorkRequestTemplateTestCase.test_data_1)
+
+        # POST a new item with correct reference
+        test_data_valid = dict(self.test_data_1)
+        test_data_valid['outputs'] = url
+        POST_and_assert_correct(self, BASE_URL + '/work_io_roles/', test_data_valid)
+
+    def test_work_io_roles_PUT_nonexistant_raises_error(self):
+        r = requests.put(BASE_URL + '/work_io_roles/9876789876/', self.test_data_1)
+        self.assertEqual(r.status_code, 404)
+
+    def test_work_io_roles_PUT(self):
+        # POST new item, verify
+        url = POST_and_assert_correct(self, BASE_URL + '/work_io_roles/', self.test_data_1)
+        GET_and_assert_correct(self, url, self.test_data_1)
+
+        # PUT new values, verify
+        PUT_and_assert_correct(self, url, self.test_data_2)
+        GET_and_assert_correct(self, url, self.test_data_2)
+
+    def test_work_io_roles_PATCH(self):
+        # POST new item, verify
+        url = POST_and_assert_correct(self, BASE_URL + '/work_io_roles/', self.test_data_1)
+        GET_and_assert_correct(self, url, self.test_data_1)
+
+        # PATCH item and verify
+        PATCH_and_assert_correct(self, url, self.test_patch)
+        expected_data = dict(self.test_data_1)
+        expected_data.update(self.test_patch)
+        GET_and_assert_correct(self, url, expected_data)
+
+    def test_work_io_roles_DELETE(self):
+        # POST new item, verify
+        url = POST_and_assert_correct(self, BASE_URL + '/work_io_roles/', self.test_data_1)
+        GET_and_assert_correct(self, url, self.test_data_1)
+
+        # DELETE and check it's gone
+        DELETE_and_assert_gone(self, url)
+
+
 if __name__ == "__main__":
     unittest.main()
 
diff --git a/SAS/LSMR/test/t_lsmrapp_models.py b/SAS/LSMR/test/t_lsmrapp_models.py
index f90acd53fb36631b1abd7e7067b4e9c7e9cd5f7e..c906eecf1153e20fefc0f48290b4229377731dce 100755
--- a/SAS/LSMR/test/t_lsmrapp_models.py
+++ b/SAS/LSMR/test/t_lsmrapp_models.py
@@ -19,6 +19,7 @@
 
 # $Id:  $
 
+import unittest
 import rest_framework.test
 from lsmr.lsmrapp import models
 
@@ -360,16 +361,16 @@ class WorkIORolesTest(rest_framework.test.APITransactionTestCase):
     reset_sequences = True
 
     # test data
-    test_data_1 = {"role": "",
-                   "datatype": "",
-                   "dataformat": [],
+    test_data_1 = {"role": "CALIBRATOR",
+                   "datatype": "INSTRUMENT_MODEL",
+                   "dataformat": ['HDF5'],
                    "outputs": None,
                    "inputs": None,
                    }
 
-    test_data_2 = {"role": "",
-                   "datatype": "",
-                   "dataformat": [],
+    test_data_2 = {"role": "TARGET",
+                   "datatype": "VISIBILITIES",
+                   "dataformat": ['MEASUREMENTSET'],
                    "outputs": None,
                    "inputs": None,
                    }
@@ -405,5 +406,39 @@ class WorkIORolesTest(rest_framework.test.APITransactionTestCase):
         for item in self.test_data_2.items():
             self.assertIn(item, response2.data.items())
 
+    @unittest.skip("Database does not enforce choices currently")
+    def test_WorkIORoles_prevents_invalid_role(self):
+        # setup
+        test_data_invalidrole = dict(self.test_data_1)
+        test_data_invalidrole['role'] = 'forbidden'
+
+        with self.assertRaises(TypeError):
+            models.WorkIORoles.objects.create(**test_data_invalidrole)
+
+
+    @unittest.skip("Database does not enforce choices currently")
+    def test_WorkIORoles_prevents_invalid_datatype(self):
+        # setup
+        test_data_invalidrole = dict(self.test_data_1)
+        test_data_invalidrole['datatype'] = 'forbidden'
+
+        with self.assertRaises(TypeError):
+            models.WorkIORoles.objects.create(**test_data_invalidrole)
+
+    @unittest.skip("Database does not enforce choices currently")
+    def test_WorkIORoles_prevents_invalid_dataformat(self):
+        # setup
+        test_data_invalidrole = dict(self.test_data_1)
+        test_data_invalidrole['dataformat'] = ['HDF5', 'forbidden']
+
+        with self.assertRaises(TypeError):
+            models.WorkIORoles.objects.create(**test_data_invalidrole)
+
+
+    # todo: def test_WorkIORoles_prevents_missing_inputs(self):
+
+    # todo: def test_WorkIORoles_prevents_missing_outputs(self):
+
+    # todo: def test_WorkIORoles_allows_existing_inputs_and_outputs(self):
 
 # todo: Tags? -> Decide how to deal with them first.