From a32636f9408385456a1dda57649b379f551e161d Mon Sep 17 00:00:00 2001
From: janmax <j.michal@stud.uni-goettingen.de>
Date: Sun, 17 Dec 2017 14:20:08 +0100
Subject: [PATCH] Started with a new mechanism to assign work to tutors

* The mechanism proposed should work as follows:
  * Tutors can subscribe to certain submission categries (currently
    this includes exam, student or type specific submissions).
    If the set of submissions to corrent is small (student) all
    submissions of that category are reserved for that tutor.
  * A reviewer should also be able to subscribe other users (delegation)
  * A subscription contains assignments or creates them:
    * Only one assignment per user may be active.
    * No new assignments can be added to a subscription after it was
      created while another assignment is present for that subscription.
    * An assignment delegates a submission to a tutor.
    * An active assignment indicates that the tutor is working on that
      assignment
    * After an assignment was finished it is deleted (or archived).
* Upgraded to Django 2.0
* Closes #66, #53.

* The mechanism remains partially incomplete as the progress in
  !67-create-new-model-feedbackline-and-integrate-it is blocking
  progress. Several tests for the API endpoint need to be written
  including. More validation and constraints might have to be added.
---
 .gitlab-ci.yml                                |   7 +-
 core/migrations/0001_initial.py               |  56 ++-
 core/migrations/0002_auto_20171110_1612.py    |  79 ----
 core/migrations/0002_auto_20171222_1116.py    |  18 +
 core/migrations/0003_auto_20180104_1631.py    |  18 +
 core/migrations/0003_student_matrikel_no.py   |  22 --
 core/migrations/0004_feedback_is_final.py     |  18 +
 core/migrations/0005_auto_20180104_1851.py    |  26 ++
 core/migrations/0006_auto_20180104_2001.py    |  20 +
 core/models.py                                | 357 +++++++++---------
 core/permissions.py                           |   5 +
 core/serializers.py                           | 115 +++++-
 core/tests/test_factory_and_feedback.py       |  28 +-
 core/tests/test_functional_views.py           |   1 +
 .../test_subscription_assignment_service.py   |  56 +++
 core/urls.py                                  |  39 +-
 core/views.py                                 | 108 +++++-
 grady/settings/default.py                     |   1 -
 grady/urls.py                                 |  30 +-
 requirements.txt                              |   4 +-
 util/factories.py                             |  69 ++--
 21 files changed, 658 insertions(+), 419 deletions(-)
 delete mode 100644 core/migrations/0002_auto_20171110_1612.py
 create mode 100644 core/migrations/0002_auto_20171222_1116.py
 create mode 100644 core/migrations/0003_auto_20180104_1631.py
 delete mode 100644 core/migrations/0003_student_matrikel_no.py
 create mode 100644 core/migrations/0004_feedback_is_final.py
 create mode 100644 core/migrations/0005_auto_20180104_1851.py
 create mode 100644 core/migrations/0006_auto_20180104_2001.py
 create mode 100644 core/tests/test_subscription_assignment_service.py

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 79e0ef5c..65006d6e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -40,16 +40,19 @@ test_pytest:
   services:
     - postgres:9.5
   script:
-    - DJANGO_SETTINGS_MODULE=grady.settings pytest --cov
+    - pytest --cov --ds=grady.settings core/tests
   artifacts:
     paths:
       - .coverage
+  cache:
+    paths:
+      - .coverage
 
 test_flake8:
   <<: *test_definition_virtualenv
   stage: test
   script:
-    - flake8 --exclude=migrations --ignore=N802 core
+    - flake8 --exclude=migrations --ignore=N802 core util/factories.py
 
 # ----------------------------- Frontend subsection -------------------------- #
 .test_template_frontend: &test_definition_frontend
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
index 5f840961..e28dd8e6 100644
--- a/core/migrations/0001_initial.py
+++ b/core/migrations/0001_initial.py
@@ -1,10 +1,13 @@
 # -*- coding: utf-8 -*-
-# Generated by Django 1.11.7 on 2017-11-04 19:10
+# Generated by Django 1.11.8 on 2017-12-22 10:51
 from __future__ import unicode_literals
 
-from typing import List, Text
+import uuid
 
+import django.contrib.auth.models
+import django.contrib.auth.validators
 import django.db.models.deletion
+import django.utils.timezone
 from django.conf import settings
 from django.db import migrations, models
 
@@ -15,7 +18,9 @@ class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies: List[Text] = []
+    dependencies = [
+        ('auth', '0008_alter_user_username_max_length'),
+    ]
 
     operations = [
         migrations.CreateModel(
@@ -24,16 +29,27 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('password', models.CharField(max_length=128, verbose_name='password')),
                 ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
-                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=150, unique=True)),
+                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+                ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
+                ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')),
+                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
                 ('fullname', models.CharField(blank=True, max_length=70, verbose_name='full name')),
-                ('is_staff', models.BooleanField(default=False, verbose_name='staff status')),
                 ('is_admin', models.BooleanField(default=False)),
-                ('is_superuser', models.BooleanField(default=False)),
-                ('is_active', models.BooleanField(default=True, verbose_name='active')),
+                ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
+                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
             ],
             options={
+                'verbose_name': 'user',
+                'verbose_name_plural': 'users',
                 'abstract': False,
             },
+            managers=[
+                ('objects', django.contrib.auth.models.UserManager()),
+            ],
         ),
         migrations.CreateModel(
             name='ExamType',
@@ -57,7 +73,6 @@ class Migration(migrations.Migration):
                 ('score', models.PositiveIntegerField(default=0)),
                 ('created', models.DateTimeField(auto_now_add=True)),
                 ('modified', models.DateTimeField(auto_now=True)),
-                ('status', models.IntegerField(choices=[(0, 'editable'), (1, 'request reassignment'), (2, 'request review'), (3, 'accepted')], default=0)),
                 ('origin', models.IntegerField(choices=[(0, 'was empty'), (1, 'passed unittests'), (2, 'did not compile'), (3, 'could not link'), (4, 'created by a human. yak!')], default=4)),
             ],
             options={
@@ -65,6 +80,15 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Feedback Set',
             },
         ),
+        migrations.CreateModel(
+            name='GeneralTaskSubscription',
+            fields=[
+                ('subscription_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+                ('query_key', models.CharField(blank=True, max_length=75)),
+                ('query_type', models.CharField(choices=[('random', 'Query for any submission'), ('student', 'Query for submissions of student'), ('exam', 'Query for submissions of exam type'), ('submission_type', 'Query for submissions of submissions_type')], default='random', max_length=75)),
+                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='susbscriptions', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
         migrations.CreateModel(
             name='Reviewer',
             fields=[
@@ -77,6 +101,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('has_logged_in', models.BooleanField(default=False)),
+                ('matrikel_no', models.CharField(default=core.models.random_matrikel_no, max_length=8, unique=True)),
                 ('exam', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='core.ExamType')),
                 ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='student', to=settings.AUTH_USER_MODEL)),
             ],
@@ -88,7 +113,7 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='Submission',
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('submission_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
                 ('seen_by_student', models.BooleanField(default=False)),
                 ('text', models.TextField(blank=True)),
                 ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='core.Student')),
@@ -134,6 +159,15 @@ class Migration(migrations.Migration):
                 ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tutor', to=settings.AUTH_USER_MODEL)),
             ],
         ),
+        migrations.CreateModel(
+            name='TutorSubmissionAssignment',
+            fields=[
+                ('assignment_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+                ('active', models.BooleanField(default=False)),
+                ('submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='assignment', to='core.Submission')),
+                ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='core.GeneralTaskSubscription')),
+            ],
+        ),
         migrations.AddField(
             model_name='submission',
             name='type',
@@ -162,4 +196,8 @@ class Migration(migrations.Migration):
             name='submission',
             unique_together=set([('type', 'student')]),
         ),
+        migrations.AlterUniqueTogether(
+            name='generaltasksubscription',
+            unique_together=set([('owner', 'query_key', 'query_type')]),
+        ),
     ]
diff --git a/core/migrations/0002_auto_20171110_1612.py b/core/migrations/0002_auto_20171110_1612.py
deleted file mode 100644
index d7eba8e7..00000000
--- a/core/migrations/0002_auto_20171110_1612.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.7 on 2017-11-10 16:12
-from __future__ import unicode_literals
-
-import django.contrib.auth.models
-import django.contrib.auth.validators
-import django.utils.timezone
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('auth', '0008_alter_user_username_max_length'),
-        ('core', '0001_initial'),
-    ]
-
-    operations = [
-        migrations.AlterModelOptions(
-            name='useraccount',
-            options={'verbose_name': 'user', 'verbose_name_plural': 'users'},
-        ),
-        migrations.AlterModelManagers(
-            name='useraccount',
-            managers=[
-                ('objects', django.contrib.auth.models.UserManager()),
-            ],
-        ),
-        migrations.AddField(
-            model_name='useraccount',
-            name='date_joined',
-            field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined'),
-        ),
-        migrations.AddField(
-            model_name='useraccount',
-            name='email',
-            field=models.EmailField(blank=True, max_length=254, verbose_name='email address'),
-        ),
-        migrations.AddField(
-            model_name='useraccount',
-            name='first_name',
-            field=models.CharField(blank=True, max_length=30, verbose_name='first name'),
-        ),
-        migrations.AddField(
-            model_name='useraccount',
-            name='groups',
-            field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
-        ),
-        migrations.AddField(
-            model_name='useraccount',
-            name='last_name',
-            field=models.CharField(blank=True, max_length=30, verbose_name='last name'),
-        ),
-        migrations.AddField(
-            model_name='useraccount',
-            name='user_permissions',
-            field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
-        ),
-        migrations.AlterField(
-            model_name='useraccount',
-            name='is_active',
-            field=models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active'),
-        ),
-        migrations.AlterField(
-            model_name='useraccount',
-            name='is_staff',
-            field=models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status'),
-        ),
-        migrations.AlterField(
-            model_name='useraccount',
-            name='is_superuser',
-            field=models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status'),
-        ),
-        migrations.AlterField(
-            model_name='useraccount',
-            name='username',
-            field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'),
-        ),
-    ]
diff --git a/core/migrations/0002_auto_20171222_1116.py b/core/migrations/0002_auto_20171222_1116.py
new file mode 100644
index 00000000..47abe7a7
--- /dev/null
+++ b/core/migrations/0002_auto_20171222_1116.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0 on 2017-12-22 11:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='useraccount',
+            name='last_name',
+            field=models.CharField(blank=True, max_length=150, verbose_name='last name'),
+        ),
+    ]
diff --git a/core/migrations/0003_auto_20180104_1631.py b/core/migrations/0003_auto_20180104_1631.py
new file mode 100644
index 00000000..f87e50dd
--- /dev/null
+++ b/core/migrations/0003_auto_20180104_1631.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.1 on 2018-01-04 16:31
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0002_auto_20171222_1116'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='tutorsubmissionassignment',
+            old_name='active',
+            new_name='is_done',
+        ),
+    ]
diff --git a/core/migrations/0003_student_matrikel_no.py b/core/migrations/0003_student_matrikel_no.py
deleted file mode 100644
index 82da5d1a..00000000
--- a/core/migrations/0003_student_matrikel_no.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# -*- coding: utf-8 -*-
-# Generated by Django 1.11.7 on 2017-11-10 21:46
-from __future__ import unicode_literals
-
-from django.db import migrations, models
-
-import core.models
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ('core', '0002_auto_20171110_1612'),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name='student',
-            name='matrikel_no',
-            field=models.CharField(default=core.models.random_matrikel_no, max_length=8, unique=True),
-        ),
-    ]
diff --git a/core/migrations/0004_feedback_is_final.py b/core/migrations/0004_feedback_is_final.py
new file mode 100644
index 00000000..7aa6f9ca
--- /dev/null
+++ b/core/migrations/0004_feedback_is_final.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.1 on 2018-01-04 16:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0003_auto_20180104_1631'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='feedback',
+            name='is_final',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/core/migrations/0005_auto_20180104_1851.py b/core/migrations/0005_auto_20180104_1851.py
new file mode 100644
index 00000000..96eea1d2
--- /dev/null
+++ b/core/migrations/0005_auto_20180104_1851.py
@@ -0,0 +1,26 @@
+# Generated by Django 2.0.1 on 2018-01-04 18:51
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0004_feedback_is_final'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tutorsubmissionassignment',
+            name='created',
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='tutorsubmissionassignment',
+            name='submission',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='core.Submission'),
+        ),
+    ]
diff --git a/core/migrations/0006_auto_20180104_2001.py b/core/migrations/0006_auto_20180104_2001.py
new file mode 100644
index 00000000..7a167247
--- /dev/null
+++ b/core/migrations/0006_auto_20180104_2001.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.0.1 on 2018-01-04 20:01
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0005_auto_20180104_1851'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='feedback',
+            name='of_tutor',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_list', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/core/models.py b/core/models.py
index 966b647d..1f7d1927 100644
--- a/core/models.py
+++ b/core/models.py
@@ -6,17 +6,21 @@ See docstring of the individual models for information on the setup of the
 database.
 '''
 
+import logging
+import uuid
 from collections import OrderedDict
 from random import randrange
-from typing import Dict, Union
+from typing import Dict
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AbstractUser
-from django.db import models
+from django.db import models, transaction
 from django.db.models import (BooleanField, Case, Count, F, IntegerField, Q,
                               QuerySet, Sum, Value, When)
 from django.db.models.functions import Coalesce
 
+log = logging.getLogger(__name__)
+
 
 def random_matrikel_no() -> str:
     """Use as a default value for student's matriculation number.
@@ -152,20 +156,29 @@ class UserAccount(AbstractUser):
             (hasattr(self, 'reviewer') and self.reviewer) or \
             (hasattr(self, 'tutor') and self.tutor)
 
+    def is_reviewer(self):
+        return hasattr(self, 'reviewer')
+
+    def is_tutor(self):
+        return hasattr(self, 'tutor')
+
+    def is_student(self):
+        return hasattr(self, 'student')
+
 
 class Tutor(models.Model):
-    user = models.OneToOneField(
-        get_user_model(), unique=True,
-        on_delete=models.CASCADE, related_name='tutor')
+    user = models.OneToOneField(get_user_model(),
+                                on_delete=models.CASCADE,
+                                related_name='tutor')
 
     def get_feedback_count(self) -> int:
         return self.feedback_list.count()
 
 
 class Reviewer(models.Model):
-    user = models.OneToOneField(
-        get_user_model(), unique=True,
-        on_delete=models.CASCADE, related_name='reviewer')
+    user = models.OneToOneField(get_user_model(),
+                                on_delete=models.CASCADE,
+                                related_name='reviewer')
 
 
 class Student(models.Model):
@@ -188,14 +201,16 @@ class Student(models.Model):
             The matriculation number of the student
     """
     has_logged_in = models.BooleanField(default=False)
-    matrikel_no = models.CharField(
-        unique=True, max_length=8, default=random_matrikel_no)
-    exam = models.ForeignKey(
-        'ExamType', on_delete=models.SET_NULL,
-        related_name='students', null=True)
-    user = models.OneToOneField(
-        get_user_model(), unique=True,
-        on_delete=models.CASCADE, related_name='student')
+    matrikel_no = models.CharField(unique=True,
+                                   max_length=8,
+                                   default=random_matrikel_no)
+    exam = models.ForeignKey('ExamType',
+                             on_delete=models.SET_NULL,
+                             related_name='students',
+                             null=True)
+    user = models.OneToOneField(get_user_model(),
+                                on_delete=models.CASCADE,
+                                related_name='student')
 
     def score_per_submission(self) -> Dict[str, int]:
         """ TODO: get rid of it and use an annotation.
@@ -255,7 +270,7 @@ class Student(models.Model):
 
 
 class Test(models.Model):
-    """Tests contain information that has been generated by automated tests,
+    """Tests contain information that has been unapproved by automated tests,
     and directly belongs to a submission. Often certain Feedback was already
     given by information provided by these tests.
 
@@ -268,16 +283,14 @@ class Test(models.Model):
     name : CharField
         The name of the test that was performed
     submission : ForeignKey
-        The submission the tests where generated on
+        The submission the tests where unapproved on
     """
     name = models.CharField(max_length=30)
     label = models.CharField(max_length=50)
     annotation = models.TextField()
-    submission = models.ForeignKey(
-        'submission',
-        related_name='tests',
-        on_delete=models.CASCADE,
-    )
+    submission = models.ForeignKey('submission',
+                                   related_name='tests',
+                                   on_delete=models.CASCADE,)
 
     class Meta:
         verbose_name = "Test"
@@ -308,6 +321,9 @@ class Submission(models.Model):
     type : OneToOneField
         Relation to the type containing meta information
     """
+    submission_id = models.UUIDField(primary_key=True,
+                                     default=uuid.uuid4,
+                                     editable=False)
     seen_by_student = models.BooleanField(default=False)
     text = models.TextField(blank=True)
     type = models.ForeignKey(
@@ -331,63 +347,6 @@ class Submission(models.Model):
             self.student
         )
 
-    @classmethod
-    def assign_tutor(cls, tutor: Tutor, slug: str=None) -> bool:
-        """Assigns a tutor to a submission
-
-        A submission is not assigned to the specified tutor in the case
-            1. the tutor already has a feedback in progress
-            2. there is no more feedback to give
-
-        Parameters
-        ----------
-        tutor : User object
-            The tutor that a submission should be assigned to.
-        slug : None, optional
-            If a slug for a submission is given the belonging Feedback is
-            assigned to the tutor. If this submission had feedback before
-            the tutor that worked on it, is unassigned.
-
-        Returns
-        -------
-        bool
-            Returns True only if feedback was actually assigned otherwise False
-
-        """
-
-        # Get a submission from the submission set
-        unfinished = Feedback.tutor_unfinished_feedback(tutor)
-        if unfinished:
-            return False
-
-        candidates = cls.objects.filter(
-            (
-                Q(feedback__isnull=True) |
-                Q(feedback__origin=Feedback.DID_NOT_COMPILE) |
-                Q(feedback__origin=Feedback.COULD_NOT_LINK) |
-                Q(feedback__origin=Feedback.FAILED_UNIT_TESTS)
-            ) &
-            ~Q(feedback__of_tutor=tutor)
-        )
-
-        # we want a submission of a specific type
-        if slug:
-            candidates = candidates.filter(type__slug=slug)
-
-        # we couldn't find any submission to correct
-        if not candidates:
-            return False
-
-        submission = candidates[0]
-        feedback = submission.feedback if hasattr(
-            submission, 'feedback') else Feedback()
-        feedback.origin = Feedback.MANUAL
-        feedback.status = Feedback.EDITABLE
-        feedback.of_tutor = tutor
-        feedback.of_submission = submission
-        feedback.save()
-        return True
-
 
 class Feedback(models.Model):
     """
@@ -404,40 +363,28 @@ class Feedback(models.Model):
         points a student receives for his submission.
     of_tutor : ForeignKey
         The tutor/reviewer how last edited the feedback
-    ORIGIN : TYPE
-        Description
     origin : IntegerField
         Of whom was this feedback originally created. She below for the choices
     score : PositiveIntegerField
         A score that has been assigned to he submission. Is final if it was
         accepted.
-    STATUS : The status determines
-        Description
-    status : PositiveIntegerField
-        The status roughly determines in which state a feedback is in. A just
-        initiated submission is editable. Based on the status feedback is
-        presented to different types of users. Students may see feedback only
-        if it has been accepted, while reviewers have access at any time.
     text : TextField
         Detailed description by the tutor about what went wrong.
         Every line in the feedback should correspond with a line in the
         students submission, maybe with additional comments appended.
-
     """
     text = models.TextField()
     score = models.PositiveIntegerField(default=0)
     created = models.DateTimeField(auto_now_add=True)
     modified = models.DateTimeField(auto_now=True)
+    is_final = models.BooleanField(default=False)
 
     of_submission = models.OneToOneField(
         Submission,
         on_delete=models.CASCADE,
-        related_name='feedback',
-        unique=True,
-        blank=False,
-        null=False)
+        related_name='feedback')
     of_tutor = models.ForeignKey(
-        Tutor,
+        get_user_model(),
         on_delete=models.SET_NULL,
         related_name='feedback_list',
         blank=True,
@@ -449,24 +396,6 @@ class Feedback(models.Model):
         blank=True,
         null=True)
 
-    # what is the current status of our feedback
-    (
-        EDITABLE,
-        OPEN,
-        NEEDS_REVIEW,
-        ACCEPTED,
-    ) = range(4)  # this order matters
-    STATUS = (
-        (EDITABLE, 'editable'),
-        (OPEN, 'request reassignment'),
-        (NEEDS_REVIEW, 'request review'),
-        (ACCEPTED, 'accepted'),
-    )
-    status = models.IntegerField(
-        choices=STATUS,
-        default=EDITABLE,
-    )
-
     # how was this feedback created
     (
         WAS_EMPTY,
@@ -500,87 +429,147 @@ class Feedback(models.Model):
     def get_full_score(self) -> int:
         return self.of_submission.type.full_score
 
-    @classmethod
-    def get_open_feedback(cls, user: Union[Tutor, Reviewer]) -> QuerySet:
-        """For a user, returns the feedback that is up for reassignment that
-        does not belong to the user.
 
-        Parameters
-        ----------
-        user : User object
-            The user for which feedback should not be returned. Often the user
-            that is currently searching for a task someone else does not want
-            to do.
+class SubscriptionEnded(Exception):
+    pass
 
-        Returns
-        -------
-        QuerySet
-            All feedback objects that are open for reassignment that do not
-            belong to the user
-        """
-        return cls.objects.filter(
-            Q(status=Feedback.OPEN) &
-            ~Q(of_tutor=user)  # you shall not request your own feedback
-        )
 
-    @classmethod
-    def tutor_unfinished_feedback(cls, user: Union[Tutor, Reviewer]):
-        """Gets only the feedback that is assigned and not accepted. A tutor
-        should have only one feedback assigned that is not accepted
+class AssignmentError(Exception):
+    pass
 
-        Parameters
-        ----------
-        user : User object
-            The tutor who formed the request
 
-        Returns
-        -------
-            The feedback or none if no feedback was assigned
-        """
-        tutor_feedback = cls.objects.filter(
-            Q(of_tutor=user), Q(status=Feedback.EDITABLE),
-        )
-        return tutor_feedback[0] if tutor_feedback else None
+class GeneralTaskSubscription(models.Model):
 
-    @classmethod
-    def tutor_assigned_feedback(cls, user: Union[Tutor, Reviewer]):
-        """Gets all feedback that is assigned to the tutor including
-        all status cases.
+    RANDOM = 'random'
+    STUDENT_QUERY = 'student'
+    EXAM_TYPE_QUERY = 'exam'
+    SUBMISSION_TYPE_QUERY = 'submission_type'
 
-        Returns
-        -------
-        a QuerySet of tasks that have been assigned to this tutor
+    type_query_mapper = {
+        RANDOM: '__any',
+        STUDENT_QUERY: 'student__user__username',
+        EXAM_TYPE_QUERY: 'student__examtype__module_reference',
+        SUBMISSION_TYPE_QUERY: 'type__title',
+    }
 
-        Parameters
-        ----------
-        user : User object
-            The user for which the feedback should be returned
-        """
-        tutor_feedback = cls.objects.filter(of_tutor=user)
-        return tutor_feedback
+    QUERY_CHOICE = (
+        (RANDOM, 'Query for any submission'),
+        (STUDENT_QUERY, 'Query for submissions of student'),
+        (EXAM_TYPE_QUERY, 'Query for submissions of exam type'),
+        (SUBMISSION_TYPE_QUERY, 'Query for submissions of submissions_type'),
+    )
 
-    def finalize_feedback(self, user: Union[Tutor, Reviewer]):
-        """Used to mark feedback as accepted (reviewed).
+    subscription_id = models.UUIDField(primary_key=True,
+                                       default=uuid.uuid4,
+                                       editable=False)
+    owner = models.ForeignKey(get_user_model(),
+                              on_delete=models.CASCADE,
+                              related_name='susbscriptions')
+    query_key = models.CharField(max_length=75, blank=True)
+    query_type = models.CharField(max_length=75,
+                                  choices=QUERY_CHOICE,
+                                  default=RANDOM)
 
-        Parameters
-        ----------
-        user : User object
-            The tutor/reviewer that marks some feedback as accepted
-        """
-        self.status = Feedback.ACCEPTED
-        self.of_reviewer = user
-        self.save()
+    class Meta:
+        unique_together = ('owner', 'query_key', 'query_type')
 
-    def reassign_to_tutor(self, user: Union[Tutor, Reviewer]):
-        """When a tutor does not want to correct some task they can pass it
-        along to another tutor who will accept the request.
+    def _get_submission_base_query(self) -> QuerySet:
+        if self.query_type == self.RANDOM:
+            return Submission.objects.all()
 
-        Parameters
-        ----------
-        User object
-            The user to which to feedback should be assigned to
-        """
-        assert self.status == Feedback.OPEN
-        self.of_tutor = user
-        self.status = Feedback.EDITABLE
+        return Submission.objects.filter(
+            **{self.type_query_mapper[self.query_type]: self.query_key})
+
+    def _find_unassigned_non_final_submissions(self):
+        unassigned_non_final_submissions = \
+            self._get_submission_base_query().filter(
+                Q(assignments__isnull=True),
+                Q(feedback__isnull=True)
+            )
+
+        log.debug('unassigned non final submissions %s',
+                  unassigned_non_final_submissions)
+
+        return unassigned_non_final_submissions
+
+    def _find_unassigned_unapproved_non_final_submissions(self):
+        unapproved_not_final_submissions = \
+            self._get_submission_base_query().filter(
+                Q(feedback__isnull=False),
+                Q(feedback__is_final=False),
+                ~Q(feedback__of_tutor=self.owner),
+                # TODO: prevent reassigning to the same tutor
+            )
+
+        log.debug('unapproved not final submissions %s',
+                  unapproved_not_final_submissions)
+
+        return unapproved_not_final_submissions
+
+    def _get_next_assignment_in_subscription(self):
+        assignment_priority = (
+            self._find_unassigned_non_final_submissions,
+            self._find_unassigned_unapproved_non_final_submissions
+        )
+
+        lazy_queries = (query_set() for query_set in assignment_priority)
+        for query in (q for q in lazy_queries if len(q) > 0):
+            return query.first()
+
+        raise SubscriptionEnded(
+            f'The task which user {self.owner} subscribed to is done')
+
+    @transaction.atomic
+    def get_or_create_work_assignment(self):
+        task = self._get_next_assignment_in_subscription()
+
+        return TutorSubmissionAssignment.objects.get_or_create(
+            subscription=self,
+            submission=task)[0]
+
+    def _create_new_assignment_if_subscription_empty(self):
+        if self.assignments.filter(is_done=False).count() < 1:
+            self.get_or_create_work_assignment()
+
+    def _eagerly_reserve_the_next_assignment(self):
+        if self.assignments.filter(is_done=False).count() < 2:
+            self.get_or_create_work_assignment()
+
+    def get_oldest_unfinished_assignment(self):
+        self._create_new_assignment_if_subscription_empty()
+        return self.assignments \
+            .filter(is_done=False) \
+            .order_by('created') \
+            .first()
+
+    def get_youngest_unfinished_assignment(self):
+        self._create_new_assignment_if_subscription_empty()
+        self._eagerly_reserve_the_next_assignment()
+        return self.assignments \
+            .filter(is_done=False) \
+            .order_by('-created') \
+            .first()
+
+
+class TutorSubmissionAssignment(models.Model):
+
+    assignment_id = models.UUIDField(primary_key=True,
+                                     default=uuid.uuid4,
+                                     editable=False)
+    submission = models.ForeignKey(Submission,
+                                   on_delete=models.CASCADE,
+                                   related_name='assignments')
+    subscription = models.ForeignKey(GeneralTaskSubscription,
+                                     on_delete=models.CASCADE,
+                                     related_name='assignments')
+    is_done = models.BooleanField(default=False)
+    created = models.DateTimeField(auto_now_add=True)
+
+    @transaction.atomic
+    def set_done(self):
+        self.is_done = True
         self.save()
+
+    def __str__(self):
+        return (f'{self.assignee} assigned to {self.submission}'
+                f' (active={self.active})')
diff --git a/core/permissions.py b/core/permissions.py
index 211ed2fb..5466fa23 100644
--- a/core/permissions.py
+++ b/core/permissions.py
@@ -45,3 +45,8 @@ class IsReviewer(IsUserGenericPermission):
 class IsTutor(IsUserGenericPermission):
     """ Has tutor permissions """
     models = (Tutor,)
+
+
+class IsTutorOrReviewer(IsUserGenericPermission):
+    """ Has tutor or reviewer permissions """
+    models = (Tutor, Reviewer,)
diff --git a/core/serializers.py b/core/serializers.py
index b64a4cf1..c767054f 100644
--- a/core/serializers.py
+++ b/core/serializers.py
@@ -1,10 +1,14 @@
 import logging
 
+from django.db import IntegrityError
+from django.core.exceptions import ObjectDoesNotExist
+
 from drf_dynamic_fields import DynamicFieldsMixin
 from rest_framework import serializers
 
-from core.models import (ExamType, Feedback, Student, Submission,
-                         SubmissionType, Test, Tutor)
+from core.models import (ExamType, Feedback, GeneralTaskSubscription, Student,
+                         Submission, SubmissionType, Tutor,
+                         TutorSubmissionAssignment)
 from util.factories import GradyUserFactory
 
 log = logging.getLogger(__name__)
@@ -37,10 +41,49 @@ class ExamSerializer(DynamicFieldsModelSerializer):
 
 
 class FeedbackSerializer(DynamicFieldsModelSerializer):
+    assignment_id = serializers.UUIDField(write_only=True)
+
+    def validate(self, data):
+        log.debug(data)
+        assignment_id = data.pop('assignment_id')
+        score = data.get('score')
+        creator = self.context.get('request').user
+
+        try:
+            assignment = TutorSubmissionAssignment.objects.get(
+                assignment_id=assignment_id)
+        except ObjectDoesNotExist as err:
+            raise serializers.ValidationError('No assignment for id')
+
+        if not assignment.subscription.owner == creator:
+            raise serializers.ValidationError('This is not your assignment')
+
+        submission = assignment.submission
+        if not 0 <= score <= submission.type.full_score:
+            raise serializers.ValidationError(
+                f'Score has to be in range [0..{submission.type.full_score}].')
+
+        if hasattr(submission, 'feedback'):
+            raise serializers.ValidationError(
+                'Feedback for this submission already exists')
+
+        return {
+            **data,
+            'assignment': assignment,
+            'of_tutor': creator,
+            'of_submission': submission
+        }
+
+    def create(self, validated_data) -> Feedback:
+        log.debug(validated_data)
+        assignment = validated_data.pop('assignment')
+        assignment.set_done()
+
+        return Feedback.objects.create(**validated_data)
 
     class Meta:
         model = Feedback
-        fields = ('text', 'score')
+        fields = ('assignment_id', 'text', 'score')
 
 
 class TestSerializer(DynamicFieldsModelSerializer):
@@ -130,3 +173,69 @@ class TutorSerializer(DynamicFieldsModelSerializer):
     class Meta:
         model = Tutor
         fields = ('username', 'feedback_count')
+
+
+class AssignmentSerializer(DynamicFieldsModelSerializer):
+    submission_id = serializers.ReadOnlyField(
+        source='submission.submission_id')
+
+    class Meta:
+        model = TutorSubmissionAssignment
+        fields = ('assignment_id', 'submission_id', 'is_done',)
+
+
+class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer):
+    text = serializers.ReadOnlyField()
+    type_id = serializers.ReadOnlyField(source='type.id')
+    full_score = serializers.ReadOnlyField(source='type.full_score')
+
+    class Meta:
+        model = Submission
+        fields = ('submission_id', 'type_id', 'text', 'full_score')
+
+
+class AssignmentDetailSerializer(DynamicFieldsModelSerializer):
+    submission = SubmissionAssignmentSerializer()
+    feedback = FeedbackSerializer(source='submission.feedback')
+
+    class Meta:
+        model = TutorSubmissionAssignment
+        fields = ('assignment_id', 'feedback', 'submission', 'is_done',)
+
+
+class SubscriptionSerializer(DynamicFieldsModelSerializer):
+    owner = serializers.ReadOnlyField(source='owner.username')
+    query_key = serializers.CharField(required=False)
+    assignments = AssignmentSerializer(read_only=True, many=True)
+
+    def validate(self, data):
+        if 'query_key' in data != \
+                data['query_type'] == GeneralTaskSubscription.RANDOM:
+            raise serializers.ValidationError(
+                f'The {data["query_type"]} query_type does not work with the'
+                f'provided key')
+
+        return data
+
+    def create(self, validated_data) -> GeneralTaskSubscription:
+        subscription = GeneralTaskSubscription.objects.create(
+            owner=self.context.get("request").user,
+            **validated_data)
+        try:
+            subscription._create_new_assignment_if_subscription_empty()
+        except IntegrityError as err:
+            log.debug(err)
+            raise
+            raise serializers.ValidationError(
+                "Oh great, you raised an IntegrityError. I'm disappointed.")
+
+        return subscription
+
+    class Meta:
+        model = GeneralTaskSubscription
+        fields = (
+            'subscription_id',
+            'owner',
+            'query_type',
+            'query_key',
+            'assignments')
diff --git a/core/tests/test_factory_and_feedback.py b/core/tests/test_factory_and_feedback.py
index 408ebb75..42ed4a4e 100644
--- a/core/tests/test_factory_and_feedback.py
+++ b/core/tests/test_factory_and_feedback.py
@@ -1,35 +1,9 @@
 from django.test import TestCase
 
-from core.models import (Feedback, Reviewer, Student, Submission,
-                         SubmissionType, Tutor)
+from core.models import Reviewer, Student, Tutor
 from util.factories import GradyUserFactory
 
 
-class FeedbackTestCase(TestCase):
-
-    factory = GradyUserFactory()
-
-    def setUp(self):
-        self.tutor = self.factory.make_tutor()
-        self.student = self.factory.make_student()
-
-        submission_type = SubmissionType.objects.create(
-            name='Cooking some crystal with Jesse')
-        Submission.objects.create(student=self.student, type=submission_type)
-        Submission.assign_tutor(self.tutor)
-
-    def test_can_assign_tutor(self):
-        self.assertEqual(self.tutor.feedback_list.count(), 1)
-
-    def test_feedback_origin_is_manual(self):
-        feedback = self.tutor.feedback_list.all()[0]
-        self.assertEqual(feedback.origin, Feedback.MANUAL)
-
-    def test_feedback_status_is_editable(self):
-        feedback = self.tutor.feedback_list.all()[0]
-        self.assertEqual(feedback.status, Feedback.EDITABLE)
-
-
 class FactoryTestCase(TestCase):
 
     factory = GradyUserFactory()
diff --git a/core/tests/test_functional_views.py b/core/tests/test_functional_views.py
index f48645b2..5a99c8b9 100644
--- a/core/tests/test_functional_views.py
+++ b/core/tests/test_functional_views.py
@@ -1,6 +1,7 @@
 from django.urls import reverse
 from rest_framework.test import (APIRequestFactory, APITestCase,
                                  force_authenticate)
+
 from core.views import get_user_role
 from util.factories import GradyUserFactory
 
diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py
new file mode 100644
index 00000000..d96aae0e
--- /dev/null
+++ b/core/tests/test_subscription_assignment_service.py
@@ -0,0 +1,56 @@
+
+from django.test import TestCase
+
+from core.models import (GeneralTaskSubscription, Submission, SubmissionType,
+                         SubscriptionEnded)
+from util.factories import GradyUserFactory
+
+
+class GeneralTaskSubscriptionRandomTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user_factory = GradyUserFactory()
+
+    def setUp(self):
+        self.t = self.user_factory.make_tutor()
+        self.s1 = self.user_factory.make_student()
+        self.s2 = self.user_factory.make_student()
+
+        self.submission_type = SubmissionType.objects.create(
+            name='submission_01', full_score=14)
+        self.submission_01 = Submission.objects.create(
+            type=self.submission_type, student=self.s1, text='I really failed')
+        self.submission_02 = Submission.objects.create(
+            type=self.submission_type, student=self.s2, text='I like apples')
+
+        self.subscription = GeneralTaskSubscription.objects.create(
+            owner=self.t.user, query_type=GeneralTaskSubscription.RANDOM)
+
+    def test_subscription_gets_an_assignment(self):
+        self.subscription._create_new_assignment_if_subscription_empty()
+        self.assertEqual(1, self.subscription.assignments.count())
+
+    def test_first_work_assignment_was_created_unfinished(self):
+        self.subscription._create_new_assignment_if_subscription_empty()
+        self.assertFalse(self.subscription.assignments.first().is_done)
+
+    def test_subscription_raises_error_when_depleted(self):
+        self.submission_01.delete()
+        self.submission_02.delete()
+        try:
+            self.subscription._create_new_assignment_if_subscription_empty()
+        except SubscriptionEnded as err:
+            self.assertFalse(False)
+        else:
+            self.assertTrue(False)
+
+    def test_can_prefetch(self):
+        self.subscription._create_new_assignment_if_subscription_empty()
+        self.subscription._eagerly_reserve_the_next_assignment()
+        self.assertEqual(2, self.subscription.assignments.count())
+
+    def test_oldest_assignment_is_current(self):
+        assignment = self.subscription.get_oldest_unfinished_assignment()
+        self.assertEqual(assignment,
+                         self.subscription.get_oldest_unfinished_assignment())
diff --git a/core/urls.py b/core/urls.py
index 2f2e47e6..7898cedd 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,35 +1,32 @@
-from django.conf.urls import include, url
 from django.contrib.staticfiles.urls import staticfiles_urlpatterns
-from django.views.generic.base import TemplateView
+from django.urls import path
 from rest_framework.routers import DefaultRouter
-from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token
 
 from core import views
 
 # Create a router and register our viewsets with it.
 router = DefaultRouter()
-router.register(r'student', views.StudentReviewerApiViewSet)
-router.register(r'examtype', views.ExamApiViewSet)
-router.register(r'submissiontype', views.SubmissionTypeApiView)
-router.register(r'tutor', views.TutorApiViewSet)
+router.register('student', views.StudentReviewerApiViewSet)
+router.register('examtype', views.ExamApiViewSet)
+router.register('feedback', views.FeedbackApiView)
+router.register('submissiontype', views.SubmissionTypeApiView)
+router.register('tutor', views.TutorApiViewSet)
+router.register('subscription', views.SubscriptionApiViewSet)
+router.register('assignment', views.AssignmentApiViewSet)
 
 # regular views that are not viewsets
 regular_views_urlpatterns = [
-    url(r'student-page', views.StudentSelfApiView.as_view(),
-        name='student-page'),
-    url(r'student-submissions', views.StudentSelfSubmissionsApiView.as_view(),
-        name='student-submissions'),
-    url(r'user-role', views.get_user_role, name='user-role'),
-    url(r'jwt-time-delta', views.get_jwt_expiration_delta,
-        name='jwt-time-delta')
+    path('student-page',
+         views.StudentSelfApiView.as_view(),
+         name='student-page'),
+    path('user-role', views.get_user_role, name='user-role'),
+    path('jwt-time-delta',
+         views.get_jwt_expiration_delta,
+         name='jwt-time-delta')
 ]
 
 urlpatterns = [
-    url(r'^api/', include(router.urls)),
-    url(r'^api/', include(regular_views_urlpatterns)),
-    url(r'^api-token-auth/', obtain_jwt_token),
-    url(r'^api-token-refresh', refresh_jwt_token),
-    url(r'^$', TemplateView.as_view(template_name='index.html')),
+    *router.urls,
+    *regular_views_urlpatterns,
+    *staticfiles_urlpatterns()
 ]
-
-urlpatterns += staticfiles_urlpatterns()
diff --git a/core/views.py b/core/views.py
index 2b87315b..f26e69d4 100644
--- a/core/views.py
+++ b/core/views.py
@@ -2,15 +2,18 @@
 can be categorized by the permissions they require. All views require a
 user to be authenticated and most are only accessible by one user group """
 from django.conf import settings
-from rest_framework import mixins, viewsets, generics
-from rest_framework.decorators import api_view
+from rest_framework import generics, mixins, status, viewsets
+from rest_framework.decorators import api_view, detail_route
 from rest_framework.response import Response
 
-from core.models import ExamType, Student, SubmissionType, Tutor
-from core.permissions import IsReviewer, IsStudent
-from core.serializers import (ExamSerializer, StudentSerializer,
-                              StudentSerializerForListView,
-                              SubmissionSerializer, SubmissionTypeSerializer,
+from core import models
+from core.models import (ExamType, Feedback, GeneralTaskSubscription, Student,
+                         SubmissionType, Tutor, TutorSubmissionAssignment)
+from core.permissions import IsReviewer, IsStudent, IsTutorOrReviewer
+from core.serializers import (AssignmentDetailSerializer, AssignmentSerializer,
+                              ExamSerializer, FeedbackSerializer,
+                              StudentSerializer, StudentSerializerForListView,
+                              SubmissionTypeSerializer, SubscriptionSerializer,
                               TutorSerializer)
 
 
@@ -51,11 +54,23 @@ class ExamApiViewSet(viewsets.ReadOnlyModelViewSet):
     serializer_class = ExamSerializer
 
 
-class TutorApiViewSet(mixins.RetrieveModelMixin,
-                      mixins.CreateModelMixin,
-                      mixins.DestroyModelMixin,
-                      mixins.ListModelMixin,
-                      viewsets.GenericViewSet):
+class FeedbackApiView(
+        mixins.CreateModelMixin,
+        mixins.RetrieveModelMixin,
+        viewsets.GenericViewSet):
+    """ Gets a list of an individual exam by Id if provided """
+    permission_classes = (IsTutorOrReviewer,)
+    queryset = Feedback.objects.all()
+    serializer_class = FeedbackSerializer
+    lookup_field = 'submission__submission_id'
+
+
+class TutorApiViewSet(
+        mixins.RetrieveModelMixin,
+        mixins.CreateModelMixin,
+        mixins.DestroyModelMixin,
+        mixins.ListModelMixin,
+        viewsets.GenericViewSet):
     """ Api endpoint for creating, listing, viewing or deleteing tutors """
     permission_classes = (IsReviewer,)
     queryset = Tutor.objects.all()
@@ -79,3 +94,72 @@ class SubmissionTypeApiView(viewsets.ReadOnlyModelViewSet):
     """ Gets a list or a detail view of a single SubmissionType """
     queryset = SubmissionType.objects.all()
     serializer_class = SubmissionTypeSerializer
+
+
+class SubscriptionApiViewSet(
+        mixins.RetrieveModelMixin,
+        mixins.CreateModelMixin,
+        mixins.ListModelMixin,
+        viewsets.GenericViewSet):
+    permission_classes = (IsTutorOrReviewer,)
+    queryset = GeneralTaskSubscription.objects.all()
+    serializer_class = SubscriptionSerializer
+
+    @detail_route(methods=['get'], url_path='assignments/current')
+    def current_assignment(self, request, pk=None):
+        subscription = self.get_object()
+        try:
+            assignment = subscription.get_oldest_unfinished_assignment()
+        except models.SubscriptionEnded as err:
+            return Response(
+                {'Error': 'This subscription has ended'},
+                status=status.HTTP_410_GONE)
+        serializer = AssignmentDetailSerializer(assignment)
+        return Response(serializer.data)
+
+    @detail_route(methods=['get'], url_path='assignments/next')
+    def next_assignment(self, request, pk=None):
+        subscription = self.get_object()
+        try:
+            assignment = subscription.get_youngest_unfinished_assignment()
+        except models.SubscriptionEnded as err:
+            return Response(
+                {'Error': 'Seems there is nothing left to prefetch'},
+                status=status.HTTP_410_GONE)
+        serializer = AssignmentDetailSerializer(assignment)
+        return Response(serializer.data)
+
+    def get_queryset(self):
+        return GeneralTaskSubscription.objects.filter(owner=self.request.user)
+
+    def destroy(self, request, pk=None):
+        instance = self.get_object()
+
+        # todo: prevent this via deactivation
+
+        instance.delete()
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class AssignmentApiViewSet(
+        mixins.RetrieveModelMixin,
+        mixins.ListModelMixin,
+        viewsets.GenericViewSet):
+    permission_classes = (IsTutorOrReviewer,)
+    queryset = TutorSubmissionAssignment.objects.all()
+    serializer_class = AssignmentSerializer
+
+    def get_queryset(self):
+        """ Get only assignments of that user """
+        return TutorSubmissionAssignment.objects.filter(
+            subscription__owner=self.request.user)
+
+    def destroy(self, request, pk=None):
+        """ Stop working on the assignment before it is finished """
+        instance = self.get_object()
+
+        if instance.is_done:
+            return Response(status=status.HTTP_403_FORBIDDEN)  # test
+
+        instance.delete()
+        return Response(status=status.HTTP_204_NO_CONTENT)  # test
diff --git a/grady/settings/default.py b/grady/settings/default.py
index 066af8ec..3be7ad73 100644
--- a/grady/settings/default.py
+++ b/grady/settings/default.py
@@ -37,7 +37,6 @@ INSTALLED_APPS = [
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
-    'django_extensions',
     'rest_framework',
     'corsheaders',
     'drf_dynamic_fields',
diff --git a/grady/urls.py b/grady/urls.py
index 5a75e52c..e36863f6 100644
--- a/grady/urls.py
+++ b/grady/urls.py
@@ -1,25 +1,15 @@
-"""grady URL Configuration
-
-The `urlpatterns` list routes URLs to views. For more information please see:
-    https://docs.djangoproject.com/en/1.10/topics/http/urls/
-Examples:
-Function views
-    1. Add an import:  from my_app import views
-    2. Add a URL to urlpatterns:  url(r'^$', views.home, name='home')
-Class-based views
-    1. Add an import:  from other_app.views import Home
-    2. Add a URL to urlpatterns:  url(r'^$', Home.as_view(), name='home')
-Including another URLconf
-    1. Import the include() function: from django.conf.urls import url, include
-    2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
-"""
-from django.conf.urls import include, url
 from django.contrib import admin
+from django.urls import include, path
+from django.views.generic.base import TemplateView
+from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token
 
 urlpatterns = [
-    url(r'^admin/', admin.site.urls),
-    url(r'^', include('core.urls')),
+    path('admin/', admin.site.urls),
+    path('api/', include('core.urls')),
+    path('api-auth/', include('rest_framework.urls',
+                              namespace='rest_framework')),
+    path('api-token-auth/', obtain_jwt_token),
+    path('api-token-refresh/', refresh_jwt_token),
+    path('', TemplateView.as_view(template_name='index.html'))
 
-    url(r'^api-auth/', include('rest_framework.urls',
-                               namespace='rest_framework')),
 ]
diff --git a/requirements.txt b/requirements.txt
index 8c712f88..c6db510e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,9 +1,9 @@
 django-cors-headers~=2.1.0
 django-extensions~=1.7.7
 djangorestframework-jwt~=1.11.0
-djangorestframework~=3.6.3
+djangorestframework~=3.7.7
 drf-dynamic-fields~=0.2.0
-Django~=1.11.8
+Django~=2.0
 gevent~=1.2.2
 gunicorn~=19.7.0
 psycopg2~=2.7.1
diff --git a/util/factories.py b/util/factories.py
index f90e649c..a27f20bd 100644
--- a/util/factories.py
+++ b/util/factories.py
@@ -2,9 +2,9 @@ import configparser
 import secrets
 import string
 
-from core.models import (Reviewer, Student, Tutor, ExamType,
-                         SubmissionType, Submission, Feedback)
 from core.models import UserAccount as User
+from core.models import (ExamType, Feedback, Reviewer, Student, Submission,
+                         SubmissionType, Tutor)
 
 STUDENTS = 'students'
 TUTORS = 'tutors'
@@ -35,15 +35,14 @@ def store_password(username, groupname, password):
 class GradyUserFactory:
 
     def __init__(self,
-                 password_generator_func=get_random_password,
+                 make_password=get_random_password,
                  password_storge=store_password,
                  *args, **kwargs):
-        self.password_generator_func = password_generator_func
+        self.make_password = make_password
         self.password_storge = password_storge
 
-    @staticmethod
-    def _get_random_name(prefix='', suffix='', k=1):
-        return ''.join((prefix, get_random_password(k), suffix))
+    def _get_random_name(self, prefix='', suffix='', k=4):
+        return ''.join((prefix, self.make_password(k), suffix))
 
     def _make_base_user(self, username, groupname, password=None,
                         store_pw=False, **kwargs):
@@ -63,10 +62,8 @@ class GradyUserFactory:
             username=username,
             defaults=kwargs)
 
-        if created or password is not None:
-            password = self.password_generator_func() if password is None \
-                else password
-            print(password)
+        if created:
+            password = self.make_password() if password is None else password
             user.set_password(password)
             user.save()
 
@@ -97,8 +94,7 @@ class GradyUserFactory:
 
         return generic_user
 
-    def make_student(self, username=None,
-                     matrikel_no=None,
+    def make_student(self, username=None, matrikel_no=None,
                      exam=None, **kwargs):
         """ Creates a student. Defaults can be passed via kwargs like in
         relation managers objects.update method. """
@@ -134,9 +130,7 @@ def make_submission_types(submission_types=[], **kwargs):
 def make_students(students=[], **kwargs):
     return [GradyUserFactory().make_student(
         username=student['username'],
-        exam=ExamType.objects.get(module_reference=student['exam']) if
-        'exam' in student else None,
-        password=student.get('password', None)
+        exam=ExamType.objects.get(module_reference=student['exam'])
     ) for student in students]
 
 
@@ -151,15 +145,11 @@ def make_reviewers(reviewers=[], **kwargs):
 
 
 def make_feedback(feedback, submission_object):
-    tutor = User.objects.get(
-        username=feedback['of_tutor']).get_associated_user()
+    feedback['of_tutor'] = User.objects.get(
+        username=feedback['of_tutor']).get_associated_user().user
     return Feedback.objects.update_or_create(
         of_submission=submission_object,
-        of_tutor=tutor,
-        defaults={
-            'text': feedback.get('text', ''),
-            'score': feedback['score']
-        })[0]
+        defaults=feedback)[0]
 
 
 def make_submissions(submissions=[], **kwargs):
@@ -223,13 +213,17 @@ def init_test_instance():
             'students': [{
                 'username': 'student01',
                 'exam': 'Test Exam 01',
-                'password': 'p'
+            },
+                {
+                'username': 'student02',
+                'exam': 'Test Exam 01',
             }],
             'tutors': [{
                 'username': 'tutor01'
             }],
             'reviewers': [{
-                'username': 'reviewer01'
+                'username': 'reviewer01',
+                'password': 'p'
             }],
             'submissions': [
                 {
@@ -245,6 +239,7 @@ def init_test_instance():
                         'text': 'Not good!',
                         'score': 5,
                         'of_tutor': 'tutor01',
+                        'is_final': True
                     }
                 },
                 {
@@ -255,12 +250,7 @@ def init_test_instance():
                             '       asasxasx\n'
                             '           lorem ipsum und so\n',
                     'type': '02. Merge this or that or maybe even this',
-                    'user': 'student01',
-                    'feedback': {
-                        'text': 'A little bit better!',
-                        'score': 10,
-                        'of_tutor': 'tutor01',
-                    },
+                    'user': 'student01'
                 },
                 {
                     'text': 'function blabl\n'
@@ -270,12 +260,17 @@ def init_test_instance():
                             '       asasxasx\n'
                             '           lorem ipsum und so\n',
                     'type': '03. This one exists for the sole purpose to test',
-                    'user': 'student01',
-                    'feedback': {
-                        'text': 'Awesome!',
-                        'score': 30,
-                        'of_tutor': 'tutor01',
-                    },
+                    'user': 'student01'
+                },
+                {
+                    'text': 'function blabl\n'
+                            '   on multi lines\n'
+                            '       for blabla in bla:\n'
+                            '   arrrgh\n'
+                            '       asasxasx\n'
+                            '           lorem ipsum und so\n',
+                    'type': '03. This one exists for the sole purpose to test',
+                    'user': 'student02'
                 },
             ]}
     )
-- 
GitLab