diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 1f103389b8104f24c64220abe9ef2f6f359d9892..3624c16e6e5350129516765df98880b8b850273d 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,15 +1,15 @@ -# Generated by Django 2.0.2 on 2018-02-10 17:00 - -import uuid +# Generated by Django 2.1.11 on 2019-12-01 16:48 +import core.models.student_info +import core.models.user_account +from django.conf import settings import django.contrib.auth.models import django.contrib.auth.validators +import django.core.validators +from django.db import migrations, models import django.db.models.deletion import django.utils.timezone -from django.conf import settings -from django.db import migrations, models - -import core.models +import uuid class Migration(migrations.Migration): @@ -34,12 +34,10 @@ class Migration(migrations.Migration): ('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')), + ('role', models.CharField(choices=[('Student', 'student'), ('Tutor', 'tutor'), ('Reviewer', 'reviewer')], max_length=50)), ('user_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('fullname', models.CharField(blank=True, max_length=70, verbose_name='full name')), ('is_admin', models.BooleanField(default=False)), - ('role', models.CharField(choices=[('Student', 'student'), ('Tutor', 'tutor'), ('Reviewer', 'reviewer')], max_length=50)), - ('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', @@ -48,6 +46,7 @@ class Migration(migrations.Migration): }, managers=[ ('objects', django.contrib.auth.models.UserManager()), + ('corrector', core.models.user_account.TutorReviewerManager()), ], ), migrations.CreateModel( @@ -68,10 +67,10 @@ class Migration(migrations.Migration): name='Feedback', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('score', models.PositiveIntegerField(default=0)), + ('score', models.DecimalField(decimal_places=2, default=0, max_digits=5)), ('created', models.DateTimeField(auto_now_add=True)), ('is_final', models.BooleanField(default=False)), - ('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)), + ('final_by_reviewer', models.BooleanField(default=False)), ], options={ 'verbose_name': 'Feedback', @@ -81,8 +80,8 @@ class Migration(migrations.Migration): migrations.CreateModel( name='FeedbackComment', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('text', models.TextField()), + ('comment_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('text', models.TextField(blank=True)), ('created', models.DateTimeField(auto_now_add=True)), ('modified', models.DateTimeField(auto_now=True)), ('visible_to_student', models.BooleanField(default=True)), @@ -96,13 +95,54 @@ class Migration(migrations.Migration): 'ordering': ('created',), }, ), + migrations.CreateModel( + name='FeedbackLabel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('description', models.TextField()), + ('colour', models.CharField(default='#b0b0b0', max_length=7, validators=[django.core.validators.RegexValidator(code='nomatch', message='Colour must be in format: #[0-9A-F]{7}', regex='^#[0-9A-F]{6}$')])), + ('feedback', models.ManyToManyField(related_name='labels', to='core.Feedback')), + ('feedback_comments', models.ManyToManyField(related_name='labels', to='core.FeedbackComment')), + ], + ), + migrations.CreateModel( + name='Group', + fields=[ + ('group_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=120)), + ], + ), + migrations.CreateModel( + name='MetaSubmission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('done_assignments', models.PositiveIntegerField(default=0)), + ('has_active_assignment', models.BooleanField(default=False)), + ('has_feedback', models.BooleanField(default=False)), + ('has_final_feedback', models.BooleanField(default=False)), + ('feedback_authors', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SolutionComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('of_line', models.PositiveIntegerField()), + ], + ), migrations.CreateModel( name='StudentInfo', fields=[ ('student_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('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')), + ('matrikel_no', models.CharField(default=core.models.student_info.random_matrikel_no, max_length=30, unique=True)), + ('total_score', models.PositiveIntegerField(default=0)), + ('passes_exam', models.BooleanField(default=False)), + ('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='students', to='core.ExamType')), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='student', to=settings.AUTH_USER_MODEL)), ], options={ @@ -116,6 +156,8 @@ class Migration(migrations.Migration): ('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)), + ('source_code', models.TextField(blank=True, editable=False, null=True)), + ('source_code_available', models.BooleanField(default=False, editable=False)), ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='core.StudentInfo')), ], options={ @@ -124,25 +166,15 @@ class Migration(migrations.Migration): 'ordering': ('type__name',), }, ), - migrations.CreateModel( - name='SubmissionSubscription', - fields=[ - ('deactivated', models.BooleanField(default=False)), - ('subscription_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('query_key', models.UUIDField(blank=True)), - ('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)), - ('feedback_stage', models.CharField(choices=[('feedback-creation', 'No feedback was ever assigned'), ('feedback-validation', 'Feedback exists but is not validated'), ('feedback-conflict-resolution', 'Previous correctors disagree')], default='feedback-creation', max_length=40)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)), - ], - ), migrations.CreateModel( name='SubmissionType', fields=[ ('submission_type_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=50, unique=True)), + ('name', models.CharField(max_length=100, unique=True)), ('full_score', models.PositiveIntegerField(default=0)), ('description', models.TextField()), ('solution', models.TextField()), + ('programming_language', models.CharField(choices=[('c', 'C syntax highlighting'), ('java', 'Java syntax highlighting'), ('mipsasm', 'Mips syntax highlighting'), ('haskell', 'Haskell syntax highlighting'), ('python', 'Python syntax highlighting'), ('plaintext', 'No syntax highlighting')], default='c', max_length=25)), ], options={ 'verbose_name': 'SubmissionType', @@ -167,10 +199,11 @@ class Migration(migrations.Migration): name='TutorSubmissionAssignment', fields=[ ('assignment_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('stage', models.CharField(choices=[('feedback-creation', 'No feedback was ever assigned'), ('feedback-validation', 'Feedback exists but is not validated'), ('feedback-review', 'Review by exam reviewer required')], default='feedback-creation', max_length=60)), ('is_done', models.BooleanField(default=False)), ('created', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to=settings.AUTH_USER_MODEL)), ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='core.Submission')), - ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='core.SubmissionSubscription')), ], ), migrations.AddField( @@ -178,19 +211,45 @@ class Migration(migrations.Migration): name='type', field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='submissions', to='core.SubmissionType'), ), + migrations.AddField( + model_name='solutioncomment', + name='of_submission_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='solution_comments', to='core.SubmissionType'), + ), + migrations.AddField( + model_name='solutioncomment', + name='of_user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='solution_comments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='metasubmission', + name='submission', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='meta', to='core.Submission'), + ), migrations.AddField( model_name='feedback', name='of_submission', field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='core.Submission'), ), + migrations.AddField( + model_name='useraccount', + name='group', + field=models.ManyToManyField(blank=True, related_name='group', to='core.Group'), + ), + 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='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.AlterUniqueTogether( name='test', unique_together={('submission', 'name')}, ), - migrations.AlterUniqueTogether( - name='submissionsubscription', - unique_together={('owner', 'query_key', 'query_type', 'feedback_stage')}, - ), migrations.AlterUniqueTogether( name='submission', unique_together={('type', 'student')}, diff --git a/core/migrations/0002_auto_20180210_1727.py b/core/migrations/0002_auto_20180210_1727.py deleted file mode 100644 index 6efb9d340dba96f298a40d0beb6411e626f9ebda..0000000000000000000000000000000000000000 --- a/core/migrations/0002_auto_20180210_1727.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.0.2 on 2018-02-10 17:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='submissionsubscription', - name='query_key', - field=models.UUIDField(null=True), - ), - ] diff --git a/core/migrations/0002_auto_20191202_1018.py b/core/migrations/0002_auto_20191202_1018.py new file mode 100644 index 0000000000000000000000000000000000000000..07cfd2d188c9282a7a55918d9aea885848028594 --- /dev/null +++ b/core/migrations/0002_auto_20191202_1018.py @@ -0,0 +1,22 @@ +# Generated by Django 2.1.11 on 2019-12-02 10:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='useraccount', + name='group', + ), + migrations.AddField( + model_name='useraccount', + name='exercise_groups', + field=models.ManyToManyField(blank=True, related_name='users', to='core.Group'), + ), + ] diff --git a/core/migrations/0003_submissiondoneassignmentscount.py b/core/migrations/0003_submissiondoneassignmentscount.py deleted file mode 100644 index 61375c886a3e202723072949ebcd13ced0cc3e27..0000000000000000000000000000000000000000 --- a/core/migrations/0003_submissiondoneassignmentscount.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 2.0.2 on 2018-02-17 12:01 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0002_auto_20180210_1727'), - ] - - operations = [ - migrations.CreateModel( - name='SubmissionDoneAssignmentsCount', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('done_assignments', models.PositiveIntegerField(default=0)), - ('submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='counter', to='core.Submission')), - ], - ), - ] diff --git a/core/migrations/0004_auto_20180217_1723.py b/core/migrations/0004_auto_20180217_1723.py deleted file mode 100644 index b87bf36814f5d52e5261c10c90d826624f6d7aa9..0000000000000000000000000000000000000000 --- a/core/migrations/0004_auto_20180217_1723.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 2.0.2 on 2018-02-17 17:23 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0003_submissiondoneassignmentscount'), - ] - - operations = [ - migrations.CreateModel( - name='MetaSubmission', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('done_assignments', models.PositiveIntegerField(default=0)), - ('has_active_assignment', models.BooleanField(default=False)), - ('has_feedback', models.BooleanField(default=False)), - ('has_final_feedback', models.BooleanField(default=False)), - ('feedback_authors', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), - ('submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='meta', to='core.Submission')), - ], - ), - migrations.RemoveField( - model_name='submissiondoneassignmentscount', - name='submission', - ), - migrations.AlterUniqueTogether( - name='tutorsubmissionassignment', - unique_together={('submission', 'subscription')}, - ), - migrations.DeleteModel( - name='SubmissionDoneAssignmentsCount', - ), - ] diff --git a/core/migrations/0005_auto_20180217_1728.py b/core/migrations/0005_auto_20180217_1728.py deleted file mode 100644 index 8506733712e2def2dd5aa26355b5d91e36818782..0000000000000000000000000000000000000000 --- a/core/migrations/0005_auto_20180217_1728.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.0.2 on 2018-02-17 17:28 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0004_auto_20180217_1723'), - ] - - operations = [ - migrations.AlterField( - model_name='metasubmission', - name='feedback_authors', - field=models.ManyToManyField(related_name='authors', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/core/migrations/0006_auto_20180217_1729.py b/core/migrations/0006_auto_20180217_1729.py deleted file mode 100644 index 3783adfbe216195018c3664103e64ec5aecd0e04..0000000000000000000000000000000000000000 --- a/core/migrations/0006_auto_20180217_1729.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.0.2 on 2018-02-17 17:29 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0005_auto_20180217_1728'), - ] - - operations = [ - migrations.AlterField( - model_name='metasubmission', - name='feedback_authors', - field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/core/migrations/0007_auto_20180217_2049.py b/core/migrations/0007_auto_20180217_2049.py deleted file mode 100644 index e1a6b83aa494f814205a8302518a01af9e3cbc40..0000000000000000000000000000000000000000 --- a/core/migrations/0007_auto_20180217_2049.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.0.2 on 2018-02-17 20:49 - -import uuid - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0006_auto_20180217_1729'), - ] - - operations = [ - migrations.RemoveField( - model_name='feedbackcomment', - name='id', - ), - migrations.AddField( - model_name='feedbackcomment', - name='comment_id', - field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), - ), - ] diff --git a/core/migrations/0008_auto_20180219_1712.py b/core/migrations/0008_auto_20180219_1712.py deleted file mode 100644 index afe81921d62cfac26894bf652477de58ba92bc7d..0000000000000000000000000000000000000000 --- a/core/migrations/0008_auto_20180219_1712.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.0.2 on 2018-02-19 17:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0007_auto_20180217_2049'), - ] - - operations = [ - migrations.AddField( - model_name='submissiontype', - name='programming_language', - field=models.CharField(choices=[('c', 'C syntax highlighting'), ('java', 'Java syntax highlighting')], default='c', max_length=25), - ), - migrations.AlterField( - model_name='submissiontype', - name='name', - field=models.CharField(max_length=100, unique=True), - ), - ] diff --git a/core/migrations/0009_auto_20180320_2335.py b/core/migrations/0009_auto_20180320_2335.py deleted file mode 100644 index 20c7419649a0bda893e4cfd28f4b72f755c529d6..0000000000000000000000000000000000000000 --- a/core/migrations/0009_auto_20180320_2335.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 2.0.2 on 2018-03-20 23:35 - -from django.db import migrations, models - -import core.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0008_auto_20180219_1712'), - ] - - operations = [ - migrations.AddField( - model_name='studentinfo', - name='passes_exam', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='studentinfo', - name='total_score', - field=models.PositiveIntegerField(default=0), - ), - migrations.AlterField( - model_name='studentinfo', - name='matrikel_no', - field=models.CharField(default=core.models.random_matrikel_no, max_length=30, unique=True), - ), - ] diff --git a/core/migrations/0010_auto_20180805_1139.py b/core/migrations/0010_auto_20180805_1139.py deleted file mode 100644 index 56c42fa08ff08c0ff3d65747abc1705d6c3ed654..0000000000000000000000000000000000000000 --- a/core/migrations/0010_auto_20180805_1139.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 2.1 on 2018-08-05 11:39 - -import core.models -import django.contrib.auth.models -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0009_auto_20180320_2335'), - ] - - operations = [ - migrations.AlterModelManagers( - name='useraccount', - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ('tutors', core.models.TutorReviewerManager()), - ], - ), - ] diff --git a/core/migrations/0011_auto_20181001_1259.py b/core/migrations/0011_auto_20181001_1259.py deleted file mode 100644 index 96582383f85820924a510a962ec6dca40a4a7745..0000000000000000000000000000000000000000 --- a/core/migrations/0011_auto_20181001_1259.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1 on 2018-10-01 12:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0010_auto_20180805_1139'), - ] - - operations = [ - migrations.AlterField( - model_name='submissiontype', - name='programming_language', - field=models.CharField(choices=[('c', 'C syntax highlighting'), ('java', 'Java syntax highlighting'), ('mipsasm', 'Mips syntax highlighting'), ('haskell', 'Haskell syntax highlighting')], default='c', max_length=25), - ), - ] diff --git a/core/migrations/0012_auto_20190306_1611.py b/core/migrations/0012_auto_20190306_1611.py deleted file mode 100644 index 54f93ca92be48e5c48a814198dec8e77668a3eb8..0000000000000000000000000000000000000000 --- a/core/migrations/0012_auto_20190306_1611.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.5 on 2019-03-06 16:11 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0011_auto_20181001_1259'), - ] - - operations = [ - migrations.AlterField( - model_name='studentinfo', - name='exam', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='students', to='core.ExamType'), - ), - ] diff --git a/core/migrations/0013_auto_20190308_1448.py b/core/migrations/0013_auto_20190308_1448.py deleted file mode 100644 index 3f2e76d83b47225f972a0469cdbd1062513fb200..0000000000000000000000000000000000000000 --- a/core/migrations/0013_auto_20190308_1448.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.4 on 2019-03-08 14:48 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0012_auto_20190306_1611'), - ] - - operations = [ - migrations.AlterField( - model_name='feedback', - name='score', - field=models.DecimalField(decimal_places=2, default=0, max_digits=5), - ), - ] diff --git a/core/migrations/0014_feedbacklabel.py b/core/migrations/0014_feedbacklabel.py deleted file mode 100644 index 14b9d385a957821ca66f102d1d51f87e11a78379..0000000000000000000000000000000000000000 --- a/core/migrations/0014_feedbacklabel.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.1.4 on 2019-04-25 15:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0013_auto_20190308_1448'), - ] - - operations = [ - migrations.CreateModel( - name='FeedbackLabel', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50)), - ('description', models.TextField()), - ('feedback', models.ManyToManyField(related_name='labels', to='core.Feedback')), - ('feedback_comments', models.ManyToManyField(related_name='labels', to='core.FeedbackComment')), - ], - ), - ] diff --git a/core/migrations/0015_feedbacklabel_colour.py b/core/migrations/0015_feedbacklabel_colour.py deleted file mode 100644 index 2d4715b56e28b3bed41bc4f322f4c06656a79d33..0000000000000000000000000000000000000000 --- a/core/migrations/0015_feedbacklabel_colour.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 2.1.4 on 2019-04-30 17:01 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0014_feedbacklabel'), - ] - - operations = [ - migrations.AddField( - model_name='feedbacklabel', - name='colour', - field=models.CharField(default='#b0b0b0', max_length=7, validators=[django.core.validators.RegexValidator(code='nomatch', message='Colour must be in format: #[0-9A-F]{7}', regex='^#[0-9A-F]{6}$')]), - ), - ] diff --git a/core/migrations/0016_auto_20190521_1803.py b/core/migrations/0016_auto_20190521_1803.py deleted file mode 100644 index 93477a02517034428177421a3ee7f6b19437d0a2..0000000000000000000000000000000000000000 --- a/core/migrations/0016_auto_20190521_1803.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.2 on 2019-05-21 18:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0015_feedbacklabel_colour'), - ] - - operations = [ - migrations.AlterField( - model_name='feedbackcomment', - name='text', - field=models.TextField(blank=True), - ), - ] diff --git a/core/migrations/0016_solutioncomment.py b/core/migrations/0016_solutioncomment.py deleted file mode 100644 index d76621d464405ace6b2a55f718b46dd6a56ed9b5..0000000000000000000000000000000000000000 --- a/core/migrations/0016_solutioncomment.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 2.1.4 on 2019-05-14 15:00 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0015_feedbacklabel_colour'), - ] - - operations = [ - migrations.CreateModel( - name='SolutionComment', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('text', models.TextField()), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), - ('of_line', models.PositiveIntegerField()), - ('of_submission_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='solution_comments', to='core.SubmissionType')), - ('of_user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='solution_comments', to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/core/migrations/0017_auto_20190604_1631.py b/core/migrations/0017_auto_20190604_1631.py deleted file mode 100644 index 55685cdd3d712c9f0c013fcf1949e4eff783caa4..0000000000000000000000000000000000000000 --- a/core/migrations/0017_auto_20190604_1631.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 2.1.4 on 2019-06-04 16:31 - -import core.models.user_account -import django.contrib.auth.models -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0016_auto_20190521_1803'), - ] - - operations = [ - migrations.AlterModelManagers( - name='useraccount', - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ('corrector', core.models.user_account.TutorReviewerManager()), - ], - ), - ] diff --git a/core/migrations/0018_auto_20190709_1526.py b/core/migrations/0018_auto_20190709_1526.py deleted file mode 100644 index 468d24424520ecc7a31f8df4c7277e6568e5f670..0000000000000000000000000000000000000000 --- a/core/migrations/0018_auto_20190709_1526.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2 on 2019-07-09 15:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0017_auto_20190604_1631'), - ] - - operations = [ - migrations.AddField( - model_name='feedback', - name='final_by_reviewer', - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name='feedbacklabel', - name='name', - field=models.CharField(max_length=50, unique=True), - ), - ] diff --git a/core/migrations/0018_auto_20190814_1324.py b/core/migrations/0018_auto_20190814_1324.py deleted file mode 100644 index 68d141b6b8dffc9442e9a1e04b5c433872e3990f..0000000000000000000000000000000000000000 --- a/core/migrations/0018_auto_20190814_1324.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.11 on 2019-08-14 13:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0017_auto_20190604_1631'), - ] - - operations = [ - migrations.AlterField( - model_name='feedbacklabel', - name='name', - field=models.CharField(max_length=50, unique=True), - ), - ] diff --git a/core/migrations/0019_merge_20190814_1437.py b/core/migrations/0019_merge_20190814_1437.py deleted file mode 100644 index be3acd875f07d9910f23a48e771324e0d1b25e51..0000000000000000000000000000000000000000 --- a/core/migrations/0019_merge_20190814_1437.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.1.11 on 2019-08-14 14:37 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0018_auto_20190709_1526'), - ('core', '0018_auto_20190814_1324'), - ] - - operations = [ - ] diff --git a/core/migrations/0020_auto_20190831_1417.py b/core/migrations/0020_auto_20190831_1417.py deleted file mode 100644 index f6f6281a8b8aea11ca7efc0f66c02b373615fb99..0000000000000000000000000000000000000000 --- a/core/migrations/0020_auto_20190831_1417.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.1.11 on 2019-08-31 14:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0019_merge_20190814_1437'), - ] - - operations = [ - migrations.AlterField( - model_name='submissiontype', - name='programming_language', - field=models.CharField(choices=[('c', 'C syntax highlighting'), ('java', 'Java syntax highlighting'), ('mipsasm', 'Mips syntax highlighting'), ('haskell', 'Haskell syntax highlighting'), ('plaintext', 'No syntax highlighting')], default='c', max_length=25), - ), - ] diff --git a/core/migrations/0021_merge_20190902_1246.py b/core/migrations/0021_merge_20190902_1246.py deleted file mode 100644 index aba1adb9618428670de6e81ec9c8c324d5e4709b..0000000000000000000000000000000000000000 --- a/core/migrations/0021_merge_20190902_1246.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.1.11 on 2019-09-02 12:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0016_solutioncomment'), - ('core', '0020_auto_20190831_1417'), - ] - - operations = [ - ] diff --git a/core/migrations/0022_auto_20191012_1539.py b/core/migrations/0022_auto_20191012_1539.py deleted file mode 100644 index 936f4923e21c8b32d5d21a3b1e1318e54a676b5f..0000000000000000000000000000000000000000 --- a/core/migrations/0022_auto_20191012_1539.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.1.11 on 2019-10-12 15:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0021_merge_20190902_1246'), - ] - - operations = [ - migrations.AddField( - model_name='submission', - name='source_code', - field=models.TextField(blank=True, editable=False, null=True), - ), - migrations.AddField( - model_name='submission', - name='source_code_available', - field=models.BooleanField(default=False, editable=False), - ), - ] diff --git a/core/migrations/0022_remove_feedback_origin.py b/core/migrations/0022_remove_feedback_origin.py deleted file mode 100644 index 798dccf984558b12ce29f2cf5a65decf7df0d114..0000000000000000000000000000000000000000 --- a/core/migrations/0022_remove_feedback_origin.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.1.13 on 2019-10-12 15:11 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0021_merge_20190902_1246'), - ] - - operations = [ - migrations.RemoveField( - model_name='feedback', - name='origin', - ), - ] diff --git a/core/migrations/0023_merge_20191013_1908.py b/core/migrations/0023_merge_20191013_1908.py deleted file mode 100644 index 070a368db74e2d2a566ec852c917a207bd94b3aa..0000000000000000000000000000000000000000 --- a/core/migrations/0023_merge_20191013_1908.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 2.1.11 on 2019-10-13 19:08 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0022_remove_feedback_origin'), - ('core', '0022_auto_20191012_1539'), - ] - - operations = [ - ] diff --git a/core/models/__init__.py b/core/models/__init__.py index 47ab0c28db6506e4a7cd3ceadcf0c5c7b3817354..02a2341c18ae74a55059916288c9bb088562d3ae 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1,3 +1,4 @@ +from .group import Group # noqa from .exam_type import ExamType # noqa from .submission_type import SubmissionType, SolutionComment # noqa from .user_account import UserAccount, TutorReviewerManager # noqa @@ -6,7 +7,7 @@ from .student_info import StudentInfo, random_matrikel_no # noqa from .test import Test # noqa from .submission import Submission, MetaSubmission # noqa from .feedback import Feedback, FeedbackComment # noqa -from .subscription import (NotMoreThanTwoOpenAssignmentsAllowed, SubmissionSubscription, # noqa - SubscriptionTemporarilyEnded, SubscriptionEnded) # noqa -from .assignment import DeletionOfDoneAssignmentsNotPermitted, TutorSubmissionAssignment # noqa +from .assignment import (DeletionOfDoneAssignmentsNotPermitted, TutorSubmissionAssignment, # noqa + CanOnlyCallFinishOnUnfinishedAssignments, SubmissionTypeDepleted, # noqa + NotMoreThanTwoOpenAssignmentsAllowed) # noqa from .label import FeedbackLabel # noqa diff --git a/core/models/assignment.py b/core/models/assignment.py index 2f9151bcbaa9416d589ad238f3a4229855715442..8936bf9ab84473704401416279d02a232f2e4765 100644 --- a/core/models/assignment.py +++ b/core/models/assignment.py @@ -4,7 +4,7 @@ import uuid import constance from django.db import models -from core.models.submission import Submission +from core.models import Submission, UserAccount, MetaSubmission log = logging.getLogger(__name__) config = constance.config @@ -18,7 +18,61 @@ class CanOnlyCallFinishOnUnfinishedAssignments(Exception): pass +class SubmissionTypeDepleted(Exception): + pass + + +class NotMoreThanTwoOpenAssignmentsAllowed(Exception): + pass + + +class TutorSubmissionAssignmentManager(models.Manager): + + @staticmethod + def available_assignments(create_assignment_options): + stage = create_assignment_options['stage'] + owner = create_assignment_options['owner'] + submission_type = create_assignment_options['submission_type'] + group = create_assignment_options.get('group') + + stage = TutorSubmissionAssignment.assignment_count_on_stage[stage] + candidates = MetaSubmission.objects.filter( + submission__type__pk=submission_type, + done_assignments=stage, + has_final_feedback=False, + has_active_assignment=False, + ).exclude( + feedback_authors=owner + ) + if group is not None: + candidates = candidates.filter( + submission__student__user__exercise_groups__pk=group + ) + return candidates + + class TutorSubmissionAssignment(models.Model): + objects = TutorSubmissionAssignmentManager() + + FEEDBACK_CREATION = 'feedback-creation' + FEEDBACK_VALIDATION = 'feedback-validation' + FEEDBACK_REVIEW = 'feedback-review' + + stages = ( + (FEEDBACK_CREATION, 'No feedback was ever assigned'), + (FEEDBACK_VALIDATION, 'Feedback exists but is not validated'), + (FEEDBACK_REVIEW, 'Review by exam reviewer required'), + ) + + assignment_count_on_stage = { + FEEDBACK_CREATION: 0, + FEEDBACK_VALIDATION: 1, + FEEDBACK_REVIEW: 2, + } + + owner = models.ForeignKey(UserAccount, + on_delete=models.CASCADE, + related_name='assignments') assignment_id = models.UUIDField(primary_key=True, default=uuid.uuid4, @@ -26,14 +80,16 @@ class TutorSubmissionAssignment(models.Model): submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name='assignments') - subscription = models.ForeignKey('SubmissionSubscription', - on_delete=models.CASCADE, - related_name='assignments') + + stage = models.CharField(choices=stages, + max_length=60, + default=FEEDBACK_CREATION) + is_done = models.BooleanField(default=False) created = models.DateTimeField(auto_now_add=True) def __str__(self): - return (f'{self.subscription.owner} assigned to {self.submission}' + return (f'{self.owner} assigned to {self.submission}' f' (done={self.is_done})') def finish(self): @@ -42,7 +98,7 @@ class TutorSubmissionAssignment(models.Model): raise CanOnlyCallFinishOnUnfinishedAssignments() meta = self.submission.meta - meta.feedback_authors.add(self.subscription.owner) + meta.feedback_authors.add(self.owner) meta.done_assignments += 1 meta.has_active_assignment = False self.is_done = True @@ -53,6 +109,3 @@ class TutorSubmissionAssignment(models.Model): if self.is_done: raise DeletionOfDoneAssignmentsNotPermitted() super().delete(*args, **kwargs) - - class Meta: - unique_together = ('submission', 'subscription') diff --git a/core/models/group.py b/core/models/group.py new file mode 100644 index 0000000000000000000000000000000000000000..02c40a1d45966c5b86a4aa1bf857b00fb7820a82 --- /dev/null +++ b/core/models/group.py @@ -0,0 +1,9 @@ +from django.db import models +import uuid + + +class Group(models.Model): + group_id = models.UUIDField(primary_key=True, + default=uuid.uuid4, + editable=False) + name = models.CharField(max_length=120) diff --git a/core/models/subscription.py b/core/models/subscription.py deleted file mode 100644 index 6788aad671723d518e0719df3338eb3d50c6e252..0000000000000000000000000000000000000000 --- a/core/models/subscription.py +++ /dev/null @@ -1,205 +0,0 @@ -import logging -import secrets -import uuid - -import constance -from django.contrib.auth import get_user_model -from django.db import models, transaction -from django.db.models import (Q, QuerySet) - -from core.models.submission import MetaSubmission -from core.models.assignment import TutorSubmissionAssignment - -log = logging.getLogger(__name__) -config = constance.config - - -class SubscriptionEnded(Exception): - pass - - -class SubscriptionTemporarilyEnded(Exception): - pass - - -class NotMoreThanTwoOpenAssignmentsAllowed(Exception): - pass - - -def get_random_element_from_queryset(queryset): - qs_elements = queryset.all() - length = len(qs_elements) - index = secrets.choice(range(length)) - return qs_elements[index] - - -class SubmissionSubscription(models.Model): - - RANDOM = 'random' - STUDENT_QUERY = 'student' - EXAM_TYPE_QUERY = 'exam' - SUBMISSION_TYPE_QUERY = 'submission_type' - - type_query_mapper = { - RANDOM: '__any', - STUDENT_QUERY: 'student__pk', - EXAM_TYPE_QUERY: 'student__exam__pk', - SUBMISSION_TYPE_QUERY: 'type__pk', - } - - 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'), - ) - - FEEDBACK_CREATION = 'feedback-creation' - FEEDBACK_VALIDATION = 'feedback-validation' - FEEDBACK_CONFLICT_RESOLUTION = 'feedback-conflict-resolution' - - assignment_count_on_stage = { - FEEDBACK_CREATION: 0, - FEEDBACK_VALIDATION: 1, - FEEDBACK_CONFLICT_RESOLUTION: 2, - } - - stages = ( - (FEEDBACK_CREATION, 'No feedback was ever assigned'), - (FEEDBACK_VALIDATION, 'Feedback exists but is not validated'), - (FEEDBACK_CONFLICT_RESOLUTION, 'Previous correctors disagree'), - ) - - deactivated = models.BooleanField(default=False) - subscription_id = models.UUIDField(primary_key=True, - default=uuid.uuid4, - editable=False) - owner = models.ForeignKey(get_user_model(), - on_delete=models.CASCADE, - related_name='subscriptions') - query_key = models.UUIDField(null=True) - query_type = models.CharField(max_length=75, - choices=QUERY_CHOICE, - default=RANDOM) - feedback_stage = models.CharField(choices=stages, - max_length=40, - default=FEEDBACK_CREATION) - - class Meta: - unique_together = ('owner', - 'query_key', - 'query_type', - 'feedback_stage') - - def _get_submission_base_query(self) -> QuerySet: - """ Get all submissions that are filtered by the query key and type, - e.g. all submissions of one student or submission type. - """ - if self.query_type == self.RANDOM: - return MetaSubmission.objects.all() - - return MetaSubmission.objects.filter( - **{'submission__' + self.type_query_mapper[self.query_type]: - self.query_key}) - - def _get_submissions_that_do_not_have_final_feedback(self) -> QuerySet: - """ There are a number of conditions to check for each submission - - 1. The submission does not have final feedback - 2. The submission was not shown to this user before - 3. The submission is not currently assigned to somebody else - - Returns: - QuerySet -- a list of all submissions ready for consumption - """ - return self._get_submission_base_query() \ - .select_for_update(of=('self',)).exclude( - Q(has_final_feedback=True) | - Q(has_active_assignment=True) | - Q(feedback_authors=self.owner) - ) - - def _get_available_submissions_in_subscription_stage(self) -> QuerySet: - """ Another filter this time it returns all the submissions that - are valid in this stage. That means all previous stages have been - completed. - - Raises: - SubscriptionEnded -- if the subscription will not yield - subscriptions in the future - SubscriptionTemporarilyEnded -- wait until new become available - """ - candidates = self._get_submissions_that_do_not_have_final_feedback() - - if candidates.count() == 0: - raise SubscriptionEnded( - f'The task which user {self.owner} subscribed to is done') - - done_assignments_count = self.assignment_count_on_stage[self.feedback_stage] # noqa - stage_candidates = candidates.filter( - done_assignments=done_assignments_count, - ) - - if stage_candidates.count() == 0: - raise SubscriptionTemporarilyEnded( - 'Currently unavailable. Please check for more soon. ' - 'Submissions remaining: %s' % stage_candidates.count()) - - if (config.STOP_ON_PASS and - self.feedback_stage == self.FEEDBACK_CREATION): - stage_candidates = stage_candidates.exclude( - Q(submission__student__passes_exam=True) & - Q(submission__student__exam__pass_only=True) - ) - - return stage_candidates - - @transaction.atomic - def get_remaining_not_final(self) -> int: - return self._get_submissions_that_do_not_have_final_feedback().count() - - @transaction.atomic - def get_available_in_stage(self) -> int: - try: - return self._get_available_submissions_in_subscription_stage().count() # noqa - except (SubscriptionTemporarilyEnded, SubscriptionEnded): - return 0 - - @transaction.atomic - def get_or_create_work_assignment(self): - taskqueryset = self._get_available_submissions_in_subscription_stage() - task = get_random_element_from_queryset(taskqueryset) - if self.assignments.filter(is_done=False).count() >= 2: - raise NotMoreThanTwoOpenAssignmentsAllowed( - 'Not more than 2 active assignments allowed.') - - log.info(f'{self.owner} is assigned to {task} ({self.feedback_stage})') - return TutorSubmissionAssignment.objects.create( - subscription=self, - submission=task.submission) - - @transaction.atomic - def reserve_all_assignments_for_a_student(self): - assert self.query_type == self.STUDENT_QUERY - - meta_submissions = self._get_submissions_that_do_not_have_final_feedback() # noqa - - for meta in meta_submissions: - submission = meta.submission - if hasattr(submission, 'assignments'): - submission.assignments.filter(is_done=False).delete() - TutorSubmissionAssignment.objects.create( - subscription=self, - submission=submission - ) - - log.info(f'Loaded all subscriptions of student {self.query_key}') - - @transaction.atomic - def delete(self): - self.assignments.filter(is_done=False).delete() - if self.assignments.count() == 0: - super().delete() - else: - self.deactivated = True - self.save() diff --git a/core/models/user_account.py b/core/models/user_account.py index c2e11d96a6fb6a3a93279ef960dfc72dfaa97628..da8426bf5da6cd133cb09eaf20e5faf44140f6dc 100644 --- a/core/models/user_account.py +++ b/core/models/user_account.py @@ -8,6 +8,8 @@ from django.db.models import (Case, Count, IntegerField, Q, Value, When) from django.apps import apps +from core.models import Group + log = logging.getLogger(__name__) config = constance.config @@ -22,18 +24,18 @@ class TutorReviewerManager(UserManager): def _get_counter(stage): return Count(Case( When( - Q(subscriptions__feedback_stage=stage) & - Q(subscriptions__assignments__is_done=True), + Q(assignments__stage=stage) & + Q(assignments__is_done=True), then=Value(1))), output_field=IntegerField()) - submission_subscription_model = apps.get_model('core', 'SubmissionSubscription') # noqa + assignment_model = apps.get_model('core', 'TutorSubmissionAssignment') # noqa return self.get_queryset() \ .annotate(feedback_created=_get_counter( - submission_subscription_model.FEEDBACK_CREATION)) \ + assignment_model.FEEDBACK_CREATION)) \ .annotate(feedback_validated=_get_counter( - submission_subscription_model.FEEDBACK_VALIDATION)) + assignment_model.FEEDBACK_VALIDATION)) class UserAccount(AbstractUser): @@ -60,6 +62,10 @@ class UserAccount(AbstractUser): default=uuid.uuid4, editable=False) + exercise_groups = models.ManyToManyField(Group, + blank=True, + related_name='users') + fullname = models.CharField('full name', max_length=70, blank=True) is_admin = models.BooleanField(default=False) diff --git a/core/serializers/__init__.py b/core/serializers/__init__.py index 4643d13adfcbabc3110212933c10f1a3722c7084..f601dbf17adef8b165efea0dd05bfa866b7b4951 100644 --- a/core/serializers/__init__.py +++ b/core/serializers/__init__.py @@ -1,10 +1,11 @@ from .common_serializers import * # noqa +from .group import GroupSerializer # noqa from .submission_type import (SubmissionTypeListSerializer, SubmissionTypeSerializer, # noqa SolutionCommentSerializer) # noqa from .feedback import (FeedbackSerializer, # noqa FeedbackCommentSerializer, # noqa VisibleCommentFeedbackSerializer) # noqa -from .subscription import * # noqa +from .assignment import * # noqa from .student import * # noqa from .submission import * # noqa from .tutor import CorrectorSerializer # noqa diff --git a/core/serializers/assignment.py b/core/serializers/assignment.py new file mode 100644 index 0000000000000000000000000000000000000000..faef140477373d827ab6c8de4d524691a35b8fd8 --- /dev/null +++ b/core/serializers/assignment.py @@ -0,0 +1,72 @@ +import secrets + +from rest_framework import serializers + +from core import models +from core.models import (Submission, TutorSubmissionAssignment) +from core.serializers import (DynamicFieldsModelSerializer, FeedbackSerializer, + TestSerializer) + + +class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer): + full_score = serializers.ReadOnlyField(source='type.full_score') + tests = TestSerializer(many=True, read_only=True) + + class Meta: + model = Submission + fields = ('pk', 'type', 'text', 'full_score', 'tests', 'source_code_available') + read_only_fields = ('text', 'type') + + +class AssignmentSerializer(DynamicFieldsModelSerializer): + + class Meta: + model = TutorSubmissionAssignment + fields = ('pk', 'submission', 'is_done', 'owner', 'stage') + read_only_fields = ('is_done', 'submission', 'owner') + + +class AssignmentDetailSerializer(AssignmentSerializer): + feedback = FeedbackSerializer(source='submission.feedback', read_only=True) + submission = SubmissionAssignmentSerializer(read_only=True) + submission_type = serializers.UUIDField(write_only=True) + group = serializers.UUIDField(write_only=True, required=False) + + class Meta: + model = TutorSubmissionAssignment + fields = ('pk', 'submission', 'feedback', 'is_done', + 'owner', 'stage', 'submission_type', 'group') + read_only_fields = ('is_done', 'submission', 'owner') + + def create(self, validated_data): + owner = self.context['request'].user + + open_assignments = TutorSubmissionAssignment.objects.filter( + owner=owner, + is_done=False, + ) + + if len(open_assignments) > 2: + raise models.NotMoreThanTwoOpenAssignmentsAllowed( + 'Not more than two active assignments allowed' + ) + + candidates = TutorSubmissionAssignment.objects.available_assignments({ + **validated_data, + 'owner': owner + }) + + length = len(candidates) + + if length == 0: + raise models.SubmissionTypeDepleted( + 'There are no submissions left for the given criteria' + ) + + index = secrets.choice(range(length)) + + return TutorSubmissionAssignment.objects.create( + submission=candidates[index].submission, + owner=owner, + stage=validated_data.get('stage') + ) diff --git a/core/serializers/common_serializers.py b/core/serializers/common_serializers.py index 94e22592b3b4f93734607b21f82092f6cde5d63c..b32d937b71f206451559183c6243aef9406c6695 100644 --- a/core/serializers/common_serializers.py +++ b/core/serializers/common_serializers.py @@ -8,6 +8,7 @@ from rest_framework import serializers from rest_framework.utils import html from core import models +from core.serializers.group import GroupSerializer from .generic import DynamicFieldsModelSerializer @@ -30,6 +31,7 @@ class TestSerializer(DynamicFieldsModelSerializer): class UserAccountSerializer(DynamicFieldsModelSerializer): + exercise_groups = GroupSerializer(many=True) def validate(self, data): password = data.get('password') @@ -44,8 +46,8 @@ class UserAccountSerializer(DynamicFieldsModelSerializer): class Meta: model = models.UserAccount - fields = ('pk', 'username', 'role', 'is_admin', 'password') - read_only_fields = ('pk', 'username', 'role', 'is_admin') + fields = ('pk', 'username', 'role', 'is_admin', 'password', 'exercise_groups') + read_only_fields = ('pk', 'username', 'role', 'is_admin', 'exercise_groups') extra_kwargs = {'password': {'write_only': True}} diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py index 000b0eb877d9fbb232e7016c45df75fc475ec482..1fdac79255a13d4e9a8e306a43274b49d2ecff33 100644 --- a/core/serializers/feedback.py +++ b/core/serializers/feedback.py @@ -1,5 +1,6 @@ import logging +import constance from django.db import transaction from rest_framework import serializers @@ -10,6 +11,7 @@ from util.factories import GradyUserFactory from .generic import DynamicFieldsModelSerializer +config = constance.config log = logging.getLogger(__name__) user_factory = GradyUserFactory() @@ -62,13 +64,12 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): if user.role == models.UserAccount.REVIEWER: return None - assignments = obj.of_submission.assignments.filter( - subscription__owner=user) + assignments = obj.of_submission.assignments.filter(owner=user) if assignments.count() == 0: return None - return assignments[0].subscription.feedback_stage + return assignments[0].stage @transaction.atomic def create(self, validated_data) -> Feedback: @@ -76,9 +77,15 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): feedback_lines = validated_data.pop('feedback_lines', []) labels = validated_data.pop('labels', []) user = self.context['request'].user - final_by_reviewer = validated_data.get('is_final', False) and \ + if config.SINGLE_CORRECTION: + is_final = True + validated_data.pop('is_final') + else: + is_final = validated_data.pop('is_final', False) + final_by_reviewer = is_final and \ user.role == UserAccount.REVIEWER feedback = Feedback.objects.create(of_submission=submission, + is_final=is_final, final_by_reviewer=final_by_reviewer, **validated_data) for label in labels: diff --git a/core/serializers/group.py b/core/serializers/group.py new file mode 100644 index 0000000000000000000000000000000000000000..b094a030baa4f9435293e094717cba50c8e1f23f --- /dev/null +++ b/core/serializers/group.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from core.models import Group + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ('pk', 'name') diff --git a/core/serializers/subscription.py b/core/serializers/subscription.py deleted file mode 100644 index 4f5d9dbfa77c584ccdf9dcf172142117ccec4459..0000000000000000000000000000000000000000 --- a/core/serializers/subscription.py +++ /dev/null @@ -1,103 +0,0 @@ -from rest_framework import serializers - -from core.models import (Submission, SubmissionSubscription, - TutorSubmissionAssignment) -from core.serializers import (DynamicFieldsModelSerializer, FeedbackSerializer, - TestSerializer) - - -class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer): - text = serializers.ReadOnlyField() - type = serializers.ReadOnlyField(source='type.pk') - full_score = serializers.ReadOnlyField(source='type.full_score') - tests = TestSerializer(many=True, read_only=True) - - class Meta: - model = Submission - fields = ('pk', 'type', 'text', 'full_score', 'tests', 'source_code_available') - - -class AssignmentSerializer(DynamicFieldsModelSerializer): - owner = serializers.StringRelatedField(source='subscription.owner') - stage = serializers.StringRelatedField( - source='subscription.feedback_stage') - - class Meta: - model = TutorSubmissionAssignment - fields = ('pk', 'submission', 'is_done', 'owner', 'stage') - read_only_fields = ('is_done', 'submission') - - -class AssignmentDetailSerializer(AssignmentSerializer): - feedback = FeedbackSerializer(source='submission.feedback', read_only=True) - submission = SubmissionAssignmentSerializer(read_only=True) - - class Meta: - model = TutorSubmissionAssignment - fields = ('pk', 'submission', 'feedback', 'is_done', 'subscription', - 'owner', 'stage') - read_only_fields = ('is_done', 'submission', 'feedback') - - def create(self, validated_data): - subscription = validated_data.get('subscription') - return subscription.get_or_create_work_assignment() - - -class SubscriptionSerializer(DynamicFieldsModelSerializer): - owner = serializers.ReadOnlyField(source='owner.username') - query_key = serializers.UUIDField(required=False) - assignments = serializers.SerializerMethodField() - remaining = serializers.SerializerMethodField() - available = serializers.SerializerMethodField() - - def get_assignments(self, subscription): - queryset = subscription.assignments.filter(is_done=False) - serializer = AssignmentDetailSerializer(queryset, many=True) - return serializer.data - - def get_remaining(self, subscription): - return subscription.get_remaining_not_final() - - def get_available(self, subscription): - return subscription.get_available_in_stage() - - def validate(self, data): - data['owner'] = self.context['request'].user - - if 'query_key' in data != \ - data['query_type'] == SubmissionSubscription.RANDOM: - raise serializers.ValidationError( - f'The {data["query_type"]} query_type does not work with the' - f'provided key') - - elif 'query_key' in data: - query_type = data.get('query_type') - query_key = data.get('query_key') - - select = SubmissionSubscription.type_query_mapper[query_type] - if Submission.objects.filter(**{select: query_key}).count() == 0: - raise serializers.ValidationError( - 'Seems no submissions exist for given query and key') - - return data - - def create(self, validated_data) -> SubmissionSubscription: - return SubmissionSubscription.objects.create(**validated_data) - - def update(self, instance, validated_data): - instance.deactivated = False - instance.save() - return instance - - class Meta: - model = SubmissionSubscription - fields = ('pk', - 'owner', - 'query_type', - 'query_key', - 'feedback_stage', - 'deactivated', - 'assignments', - 'remaining', - 'available') - read_only_fields = ('deactivated',) diff --git a/core/tests/test_assignment_views.py b/core/tests/test_assignment_views.py new file mode 100644 index 0000000000000000000000000000000000000000..3c2373c0f80f508f0be08c86d5f631ba7713f316 --- /dev/null +++ b/core/tests/test_assignment_views.py @@ -0,0 +1,306 @@ +from rest_framework import status +from rest_framework.test import APIClient, APITestCase + +from core import models +from core.models import (TutorSubmissionAssignment) +from util.factories import make_test_data + + +class TestApiEndpoints(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.data = make_test_data(data_dict={ + 'exams': [{ + 'module_reference': 'Test Exam 01', + 'total_score': 100, + 'pass_score': 60, + }], + 'submission_types': [ + { + 'name': '01. Sort this or that', + 'full_score': 35, + 'description': 'Very complicated', + 'solution': 'Trivial!' + }, + { + 'name': '02. Merge this or that or maybe even this', + 'full_score': 35, + 'description': 'Very complicated', + 'solution': 'Trivial!' + } + ], + 'students': [ + { + 'username': 'student01', + 'exam': 'Test Exam 01' + }, + { + 'username': 'student02', + 'exam': 'Test Exam 01' + } + ], + 'tutors': [ + {'username': 'tutor01'}, + {'username': 'tutor02'} + ], + 'reviewers': [ + {'username': 'reviewer'} + ], + 'submissions': [ + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' for blabla in bla:\n' + ' lorem ipsum und so\n', + 'type': '01. Sort this or that', + 'user': 'student01', + 'feedback': { + 'score': 5, + 'is_final': True, + 'feedback_lines': { + '1': [{ + 'text': 'This is very bad!', + 'of_tutor': 'tutor01' + }], + } + + } + }, + { + 'text': 'function blabl\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '02. Merge this or that or maybe even this', + 'user': 'student01' + }, + { + 'text': 'function blabl\n' + ' on multi lines\n' + ' asasxasx\n' + ' lorem ipsum und so\n', + 'type': '01. Sort this or that', + 'user': 'student02' + }, + { + 'text': 'function lorem ipsum etc\n', + 'type': '02. Merge this or that or maybe even this', + 'user': 'student02' + }, + ]} + ) + + def setUp(self): + self.client = APIClient() + + def test_tutor_gets_an_assignment(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + response = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + + # we should simply test if any newly created assignment is unfinished + def test_first_work_assignment_was_created_unfinished(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + self.assertFalse(TutorSubmissionAssignment.objects.first().is_done) + + def test_assignment_raises_error_when_depleted(self): + self.data['submissions'][0].delete() + self.data['submissions'][2].delete() + + self.client.force_authenticate(user=self.data['tutors'][0]) + + response = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code) + + def test_assignment_delete_of_done_not_permitted(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + first = TutorSubmissionAssignment.objects.first() + first.is_done = True + first.save() + + self.assertRaises(models.DeletionOfDoneAssignmentsNotPermitted, + first.delete) + + def test_assignment_delete_undone_permitted(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + first = TutorSubmissionAssignment.objects.first() + first.delete() + + self.assertEqual(0, TutorSubmissionAssignment.objects.all().count()) + + def tutor_can_release_own_unfinished_assignments(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + response = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + + response = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + self.client.post( + f'/api/assignment/{response.data["pk"]}/finish/', { + "score": 23, + "of_submission": response.data['submission']['pk'], + "feedback_lines": { + 1: {"text": "< some string >", "labels": []}, + 2: {"text": "< some string >", "labels": []} + }, + "labels": [], + } + ) + self.assertEqual(2, TutorSubmissionAssignment.objects.all().count()) + self.assertEqual(1, TutorSubmissionAssignment.objects.filter(is_done=True).count()) + + self.client.force_authenticate(user=self.data['tutors'][1]) + + response = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + + response = self.client.delete('/api/assignment/') + self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) + self.assertEqual(2, TutorSubmissionAssignment.objects.all().count()) + + def test_two_tutors_cant_have_assignments_for_same_submission(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + assignment_fst_tutor = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][1].pk, + "stage": "feedback-creation", + }).data + + self.client.force_authenticate(user=self.data['tutors'][1]) + + assignment_snd_tutor = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][1].pk, + "stage": "feedback-creation", + }).data + + self.assertNotEqual(assignment_fst_tutor['submission']['pk'], + assignment_snd_tutor['submission']['pk']) + + def test_reviewer_can_get_active_assignments(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + assignment = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }).data + + # tutors shouldn't have access + res = self.client.get('/api/assignment/active/') + self.assertEqual(status.HTTP_403_FORBIDDEN, res.status_code) + + self.client.force_authenticate(user=self.data['reviewers'][0]) + + active_assignments = self.client.get('/api/assignment/active/').data + self.assertIn(assignment['pk'], [assignment['pk'] for assignment in active_assignments]) + + def test_reviewer_can_delete_active_assignments(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + assignment = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }).data + + # tutors shouldn't have access + res = self.client.delete('/api/assignment/active/') + self.assertEqual(status.HTTP_403_FORBIDDEN, res.status_code) + + self.client.force_authenticate(user=self.data['reviewers'][0]) + + res = self.client.delete('/api/assignment/active/') + self.assertEqual(status.HTTP_204_NO_CONTENT, res.status_code) + self.assertNotIn( + assignment['pk'], + [assignment.pk for assignment + in TutorSubmissionAssignment.objects.filter(is_done=False)] + ) + + def test_all_stages_of_the_subscription(self): + self.client.force_authenticate(user=self.data['tutors'][0]) + + response = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-creation", + }) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + response = self.client.post( + f'/api/assignment/{response.data["pk"]}/finish/', { + "score": 23, + "of_submission": response.data['submission']['pk'], + "feedback_lines": { + 1: {"text": "< some string >", "labels": []}, + 2: {"text": "< some string >", "labels": []} + }, + "labels": [], + } + ) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + + # some other tutor reviews it + self.client.force_authenticate(user=self.data['tutors'][1]) + + response = self.client.post('/api/assignment/', { + "submission_type": self.data['submission_types'][0].pk, + "stage": "feedback-validation", + }) + + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + submission_id_in_database = models.Feedback.objects.filter( + is_final=False).first().of_submission.submission_id + submission_id_in_response = response.data['submission']['pk'] + + self.assertEqual( + str(submission_id_in_database), + submission_id_in_response) + + assignment = models.TutorSubmissionAssignment.objects.get(pk=response.data['pk']) + self.assertFalse(assignment.is_done) + response = self.client.post( + f'/api/assignment/{assignment.pk}/finish/', { + "score": 20, + "is_final": True, + "feedback_lines": { + 2: {"text": "< some addition by second tutor>"}, + } + } + ) + + assignment.refresh_from_db() + meta = assignment.submission.meta + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(2, len(response.data['feedback_lines'][2])) + self.assertTrue(assignment.is_done) + self.assertIn(self.data['tutors'][0], meta.feedback_authors.all()) + self.assertIn(self.data['tutors'][1], meta.feedback_authors.all()) diff --git a/core/tests/test_custom_subscription_filter.py b/core/tests/test_custom_subscription_filter.py deleted file mode 100644 index c6ac17671dc294c5d314d187a9053b197d09e880..0000000000000000000000000000000000000000 --- a/core/tests/test_custom_subscription_filter.py +++ /dev/null @@ -1,205 +0,0 @@ -import constance -from rest_framework.test import APITestCase - -from core.models import Feedback, SubmissionSubscription -from util.factories import make_test_data - -config = constance.config - - -class StopOnPass(APITestCase): - - @classmethod - def setUpTestData(cls): - config.STOP_ON_PASS = True - - def setUp(self): - self.data = make_test_data(data_dict={ - 'exams': [ - { - 'module_reference': 'Test Exam 01', - 'total_score': 50, - 'pass_score': 25, - 'pass_only': True - }, - { - 'module_reference': 'Test Exam 02', - 'total_score': 50, - 'pass_score': 25, - 'pass_only': False - } - ], - 'submission_types': [ - { - 'name': '01. Sort this or that', - 'full_score': 20, - 'description': 'Very complicated', - 'solution': 'Trivial!' - }, - { - 'name': '02. Merge this or that or maybe even this', - 'full_score': 15, - 'description': 'Very complicated', - 'solution': 'Trivial!' - }, - { - 'name': '03. Super simple task', - 'full_score': 15, - 'description': 'Very complicated', - 'solution': 'Trivial!' - }, - ], - 'students': [ - {'username': 'student01', 'exam': 'Test Exam 01'}, - {'username': 'student02', 'exam': 'Test Exam 02'}, - ], - 'tutors': [ - {'username': 'tutor01'}, - {'username': 'tutor02'} - ], - 'reviewers': [ - {'username': 'reviewer'} - ], - 'submissions': [ - # Student 1 - { - 'text': 'function blabl\n' - ' on multi lines\n' - ' for blabla in bla:\n' - ' lorem ipsum und so\n', - 'type': '01. Sort this or that', - 'user': 'student01', - }, - { - 'text': 'function blabl\n' - ' asasxasx\n' - ' lorem ipsum und so\n', - 'type': '02. Merge this or that or maybe even this', - 'user': 'student01' - }, - { - 'text': 'function blabl\n' - ' on multi lines\n' - ' asasxasx\n' - ' lorem ipsum und so\n', - 'type': '03. Super simple task', - 'user': 'student01' - }, - - # Student 2 - { - 'text': 'function blabl\n' - ' on multi lines\n' - ' for blabla in bla:\n' - ' lorem ipsum und so\n', - 'type': '01. Sort this or that', - 'user': 'student02', - }, - { - 'text': 'function blabl\n' - ' asasxasx\n' - ' lorem ipsum und so\n', - 'type': '02. Merge this or that or maybe even this', - 'user': 'student02' - }, - { - 'text': 'function blabl\n' - ' on multi lines\n' - ' asasxasx\n' - ' lorem ipsum und so\n', - 'type': '03. Super simple task', - 'user': 'student02' - } - ]} - ) - self.tutor01 = self.data['tutors'][0] - self.tutor02 = self.data['tutors'][1] - self.subscription = SubmissionSubscription.objects.create( - owner=self.tutor01, - feedback_stage=SubmissionSubscription.FEEDBACK_CREATION) - - def test_all_feedback_is_available(self): - self.assertEqual(6, self.subscription.get_available_in_stage()) - - def test_all_is_available_when_score_is_too_low(self): - Feedback.objects.create( - of_submission=self.data['submissions'][0], score=20, is_final=True) - self.assertEqual(5, self.subscription.get_available_in_stage()) - - def test_default_does_not_pass_exam(self): - self.assertFalse(self.data['students'][0].student.passes_exam) - - def test_no_more_submissions_after_pass_only_student_passed_exam(self): - Feedback.objects.create( - of_submission=self.data['submissions'][0], score=20) - Feedback.objects.create( - of_submission=self.data['submissions'][1], score=15) - - self.data['students'][0].student.refresh_from_db() - self.assertEqual(3, self.subscription.get_available_in_stage()) - self.assertEqual(35, self.data['students'][0].student.total_score) - self.assertTrue(self.data['students'][0].student.passes_exam) - - # TODO why is this code commented?!? - # def test_submissions_left_after_not_pass_only_student_passed_exam(self): - # Feedback.objects.create( - # of_submission=self.data['submissions'][3], score=20) - # Feedback.objects.create( - # of_submission=self.data['submissions'][4], score=15) - # - # self.data['students'][1].student.refresh_from_db() - # self.assertEqual(4, self.subscription.get_available_in_stage()) - # self.assertEqual(35, self.data['students'][1].student.total_score) - # self.assertTrue(self.data['students'][1].student.passes_exam) - - def test_validation_still_allowed_when_stop_on_pass(self): - subscription_s1 = SubmissionSubscription.objects.create( - owner=self.tutor01, - feedback_stage=SubmissionSubscription.FEEDBACK_CREATION, - query_type=SubmissionSubscription.STUDENT_QUERY, - query_key=self.data['students'][0].student.pk - ) - a1 = subscription_s1.get_or_create_work_assignment() - a2 = subscription_s1.get_or_create_work_assignment() - - # signals recognize the open assignments - Feedback.objects.create( - of_submission=a1.submission, score=20) - a1.finish() - Feedback.objects.create( - of_submission=a2.submission, score=15) - a2.finish() - - subscription_other_tutor = SubmissionSubscription.objects.create( - owner=self.tutor02, - feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION) - - self.assertEqual(0, subscription_s1.get_available_in_stage()) - self.assertEqual(2, subscription_other_tutor.get_available_in_stage()) - self.assertEqual(6, subscription_other_tutor.get_remaining_not_final()) - - def test_validation_still_allowed_when_not_stop_on_pass(self): - subscription_s1 = SubmissionSubscription.objects.create( - owner=self.tutor01, - feedback_stage=SubmissionSubscription.FEEDBACK_CREATION, - query_type=SubmissionSubscription.STUDENT_QUERY, - query_key=self.data['students'][1].student.pk - ) - a1 = subscription_s1.get_or_create_work_assignment() - a2 = subscription_s1.get_or_create_work_assignment() - - # signals recognize the open assignments - Feedback.objects.create( - of_submission=a1.submission, score=20) - a1.finish() - Feedback.objects.create( - of_submission=a2.submission, score=15) - a2.finish() - - subscription_other_tutor = SubmissionSubscription.objects.create( - owner=self.tutor02, - feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION) - - self.assertEqual(1, subscription_s1.get_available_in_stage()) - self.assertEqual(2, subscription_other_tutor.get_available_in_stage()) - self.assertEqual(6, subscription_other_tutor.get_remaining_not_final()) diff --git a/core/tests/test_feedback.py b/core/tests/test_feedback.py index 7217cc8630cfcfd00b4dc96ac666a839e199c235..03947c34515f3229663748031e89f167ab5fc067 100644 --- a/core/tests/test_feedback.py +++ b/core/tests/test_feedback.py @@ -3,8 +3,9 @@ import unittest from rest_framework import status from rest_framework.test import APIRequestFactory, APITestCase -from core import models -from core.models import Feedback, FeedbackComment, Submission, SubmissionType, FeedbackLabel +from core.models import (Feedback, FeedbackComment, + Submission, SubmissionType, + FeedbackLabel, TutorSubmissionAssignment) from util.factories import GradyUserFactory, make_test_data, make_exams @@ -121,11 +122,10 @@ class FeedbackCreateTestCase(APITestCase): self.fst_label.refresh_from_db() self.snd_label.refresh_from_db() self.client.force_authenticate(user=self.tutor) - self.subscription = models.SubmissionSubscription.objects.create( + self.assignment = TutorSubmissionAssignment.objects.create( + submission=Submission.objects.first(), owner=self.tutor, - query_type='random' ) - self.assignment = self.subscription.get_or_create_work_assignment() def test_cannot_create_feedback_without_feedback_lines(self): # TODO this test has to be adapted to test the various constraints @@ -395,11 +395,10 @@ class FeedbackPatchTestCase(APITestCase): self.tutor01 = self.data['tutors'][0] self.tutor02 = self.data['tutors'][1] self.client.force_authenticate(user=self.tutor01) - self.subscription = models.SubmissionSubscription.objects.create( + self.assignment = TutorSubmissionAssignment.objects.create( + submission=Submission.objects.first(), owner=self.tutor01, - query_type='random', ) - self.assignment = self.subscription.get_or_create_work_assignment() data = { 'score': 35, 'is_final': False, @@ -455,12 +454,11 @@ class FeedbackPatchTestCase(APITestCase): @unittest.expectedFailure def test_tutor_can_not_update_when_there_is_a_new_assignment(self): # Step 1 - Create a new assignment for Tutor 2 - second_subs = models.SubmissionSubscription.objects.create( + TutorSubmissionAssignment.objects.create( + submission=Submission.objects.last(), owner=self.tutor02, - query_type='random', - feedback_stage='feedback-validation' + stage='feedback-validation', ) - second_subs.get_or_create_work_assignment() # Step 2 - Tutor 1 tries to patch data = { diff --git a/core/tests/test_submissiontypeview.py b/core/tests/test_submissiontypeview.py index 3973d7de02c9b51a3603016941fba0ed85ca6bbe..2db82648570606847d6a0a28226651782f234edc 100644 --- a/core/tests/test_submissiontypeview.py +++ b/core/tests/test_submissiontypeview.py @@ -10,6 +10,9 @@ from core.views import SubmissionTypeApiView from util.factories import GradyUserFactory +# TODO: add tests to test the remaining counts in conjunction with the assignment logic +# TODO: also test for pass only and stuff + class SubmissionTypeViewTestList(APITestCase): @classmethod diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py deleted file mode 100644 index c4522609815a3e07c7610032f334ac5bcae009fd..0000000000000000000000000000000000000000 --- a/core/tests/test_subscription_assignment_service.py +++ /dev/null @@ -1,433 +0,0 @@ -from rest_framework import status -from rest_framework.test import APIClient, APITestCase - -from core import models -from core.models import (Submission, SubmissionSubscription, SubmissionType, - SubscriptionEnded, SubscriptionTemporarilyEnded, TutorSubmissionAssignment) -from util.factories import GradyUserFactory, make_test_data, make_exams - - -class SubmissionSubscriptionRandomTest(APITestCase): - - @classmethod - def setUpTestData(cls): - cls.user_factory = GradyUserFactory() - - def setUp(self): - self.t = self.user_factory.make_tutor() - self.exam = make_exams(exams=[{ - 'module_reference': 'Test Exam 01', - 'total_score': 100, - 'pass_score': 60, - }])[0] - self.s1 = self.user_factory.make_student(exam=self.exam) - self.s2 = self.user_factory.make_student(exam=self.exam) - - 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.student, - text='I really failed') - self.submission_02 = Submission.objects.create( - type=self.submission_type, student=self.s2.student, - text='I like apples') - - self.subscription = SubmissionSubscription.objects.create( - owner=self.t, query_type=SubmissionSubscription.RANDOM) - - def test_subscription_gets_an_assignment(self): - self.subscription.get_or_create_work_assignment() - self.assertEqual(1, self.subscription.assignments.count()) - - def test_first_work_assignment_was_created_unfinished(self): - self.subscription.get_or_create_work_assignment() - 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.get_or_create_work_assignment() - except SubscriptionEnded: - self.assertFalse(False) - else: - self.assertTrue(False) - - def test_can_prefetch(self): - self.subscription.get_or_create_work_assignment() - self.subscription.get_or_create_work_assignment() - self.assertEqual(2, self.subscription.assignments.count()) - - def test_new_subscription_is_temporarily_unavailabe(self): - validation = SubmissionSubscription.objects.create( - owner=self.t, query_type=SubmissionSubscription.RANDOM, - feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION) - try: - validation.get_or_create_work_assignment() - except SubscriptionTemporarilyEnded: - self.assertTrue(True) - else: - self.assertTrue(False) - - def test_delete_with_done_assignments_subscription_remains(self): - first = self.subscription.get_or_create_work_assignment() - self.subscription.get_or_create_work_assignment() - self.assertEqual(2, self.subscription.assignments.count()) - - first.is_done = True - first.save() - self.subscription.delete() - self.assertEqual(1, self.subscription.assignments.count()) - - def test_delete_without_done_assignments_subscription_is_deleted(self): - self.subscription.delete() - self.assertEqual(0, models.SubmissionSubscription.objects.count()) - - def test_assignment_delete_of_done_not_permitted(self): - first = self.subscription.get_or_create_work_assignment() - first.is_done = True - first.save() - - self.assertRaises(models.DeletionOfDoneAssignmentsNotPermitted, - first.delete) - - def test_assignment_delete_undone_permitted(self): - first = self.subscription.get_or_create_work_assignment() - first.delete() - self.assertEqual(0, self.subscription.assignments.count()) - - -class TestApiEndpoints(APITestCase): - - @classmethod - def setUpTestData(cls): - cls.data = make_test_data(data_dict={ - 'exams': [{ - 'module_reference': 'Test Exam 01', - 'total_score': 100, - 'pass_score': 60, - }], - 'submission_types': [ - { - 'name': '01. Sort this or that', - 'full_score': 35, - 'description': 'Very complicated', - 'solution': 'Trivial!' - }, - { - 'name': '02. Merge this or that or maybe even this', - 'full_score': 35, - 'description': 'Very complicated', - 'solution': 'Trivial!' - } - ], - 'students': [ - { - 'username': 'student01', - 'exam': 'Test Exam 01' - }, - { - 'username': 'student02', - 'exam': 'Test Exam 01' - } - ], - 'tutors': [ - {'username': 'tutor01'}, - {'username': 'tutor02'} - ], - 'reviewers': [ - {'username': 'reviewer'} - ], - 'submissions': [ - { - 'text': 'function blabl\n' - ' on multi lines\n' - ' for blabla in bla:\n' - ' lorem ipsum und so\n', - 'type': '01. Sort this or that', - 'user': 'student01', - 'feedback': { - 'score': 5, - 'is_final': True, - 'feedback_lines': { - '1': [{ - 'text': 'This is very bad!', - 'of_tutor': 'tutor01' - }], - } - - } - }, - { - 'text': 'function blabl\n' - ' asasxasx\n' - ' lorem ipsum und so\n', - 'type': '02. Merge this or that or maybe even this', - 'user': 'student01' - }, - { - 'text': 'function blabl\n' - ' on multi lines\n' - ' asasxasx\n' - ' lorem ipsum und so\n', - 'type': '01. Sort this or that', - 'user': 'student02' - }, - { - 'text': 'function lorem ipsum etc\n', - 'type': '02. Merge this or that or maybe even this', - 'user': 'student02' - }, - ]} - ) - - def setUp(self): - self.client = APIClient() - - def test_ramaining_submissions_for_student(self): - self.client.force_authenticate(user=self.data['reviewers'][0]) - - student = self.data['students'][0] - - response = self.client.post( - '/api/subscription/', { - 'query_type': 'student', - 'query_key': student.student.student_id, - 'stage': 'feedback-creation' - }) - - # This is expected since we wanted to assign all student submissions - self.assertEqual(0, response.data['remaining']) - self.assertEqual(0, response.data['available']) - - def test_remaining_submissions(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - - response = self.client.post('/api/subscription/', {'query_type': 'random'}) - - self.assertEqual(3, response.data['remaining']) - - def test_available_submissions_in_stage(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - - response = self.client.post('/api/subscription/', - {'query_type': 'random', - 'feedback_stage': 'feedback-validation'}) - - self.assertEqual(0, response.data['available']) - - def test_can_create_a_subscription(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - - response = self.client.post('/api/subscription/', {'query_type': 'random'}) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_create_subscription_and_get_one_assignment(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - - response = self.client.post('/api/subscription/', {'query_type': 'random'}) - - self.assertEqual('tutor01', response.data['owner']) - - def test_subscription_has_next_and_current_assignment(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - - response_subscription_create = self.client.post( - '/api/subscription/', {'query_type': 'random'}) - - subscription_pk = response_subscription_create.data['pk'] - response_assignment = self.client.post( - f'/api/assignment/', { - 'subscription': subscription_pk - }) - - assignment_pk = response_assignment.data['pk'] - response_subscription = self.client.get( - f'/api/subscription/{subscription_pk}/') - - self.assertEqual(1, len(response_subscription.data['assignments'])) - self.assertEqual(response_assignment.data['pk'], - response_subscription.data['assignments'][0]['pk']) - - subscription_pk = response_subscription.data['pk'] - response_next = self.client.post( - f'/api/assignment/', { - 'subscription': subscription_pk - }) - response_detail_subs = \ - self.client.get(f'/api/subscription/{subscription_pk}/') - - self.assertEqual(2, len(response_detail_subs.data['assignments'])) - self.assertNotEqual(assignment_pk, response_next.data['pk']) - - def test_subscription_can_assign_to_student(self): - self.client.force_authenticate(user=self.data['reviewers'][0]) - - student = self.data['students'][0] - - response = self.client.post( - '/api/subscription/', { - 'query_type': 'student', - 'query_key': student.student.student_id, - 'stage': 'feedback-creation' - }) - - assignments = response.data['assignments'] - self.assertEqual(1, len(assignments)) - - def test_two_tutors_cant_have_assignments_for_same_submission(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - - subscription = self.client.post('/api/subscription/', - {'query_type': 'random'}).data - - assignment_fst_tutor = self.client.post( - '/api/assignment/', { - 'subscription': subscription['pk'] - } - ).data - - self.client.force_authenticate(user=self.data['tutors'][1]) - - subscription = self.client.post('/api/subscription/', - {'query_type': 'random'}).data - - assignment_snd_tutor = self.client.post( - '/api/assignment/', { - 'subscription': subscription['pk'] - } - ).data - - self.assertNotEqual(assignment_fst_tutor['submission']['pk'], - assignment_snd_tutor['submission']['pk']) - - def test_reviewer_can_get_active_assignments(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - subscription = self.client.post('/api/subscription/', - {'query_type': 'random'}).data - - assignment = self.client.post( - '/api/assignment/', { - 'subscription': subscription['pk'] - } - ).data - - # tutors shouldn't have access - res = self.client.get( - '/api/assignment/active/' - ) - self.assertEqual(status.HTTP_403_FORBIDDEN, res.status_code) - - self.client.force_authenticate(user=self.data['reviewers'][0]) - active_assignments = self.client.get( - '/api/assignment/active/' - ).data - print(assignment) - self.assertIn(assignment['pk'], [assignment['pk'] for assignment in active_assignments]) - - def test_reviewer_can_delete_active_assignments(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - subscription = self.client.post('/api/subscription/', - {'query_type': 'random'}).data - - assignment = self.client.post( - '/api/assignment/', { - 'subscription': subscription['pk'] - } - ).data - - # tutors shouldn't have access - res = self.client.delete( - '/api/assignment/active/' - ) - self.assertEqual(status.HTTP_403_FORBIDDEN, res.status_code) - - self.client.force_authenticate(user=self.data['reviewers'][0]) - res = self.client.delete( - '/api/assignment/active/' - ) - self.assertEqual(status.HTTP_204_NO_CONTENT, res.status_code) - self.assertNotIn( - assignment['pk'], - [assignment.pk for assignment - in TutorSubmissionAssignment.objects.filter(is_done=False)] - ) - - def test_all_stages_of_the_subscription(self): - self.client.force_authenticate(user=self.data['tutors'][0]) - - # The tutor corrects something - response = self.client.post( - '/api/subscription/', { - 'query_type': 'random', - 'feedback_stage': 'feedback-creation' - }) - - self.assertEqual(status.HTTP_201_CREATED, response.status_code) - - subscription_pk = response.data['pk'] - response = self.client.post( - f'/api/assignment/', { - 'subscription': subscription_pk - }) - self.assertEqual(status.HTTP_201_CREATED, response.status_code) - response = self.client.post( - f'/api/assignment/{response.data["pk"]}/finish/', { - "score": 23, - "of_submission": response.data['submission']['pk'], - "feedback_lines": { - 1: {"text": "< some string >", "labels": []}, - 2: {"text": "< some string >", "labels": []} - }, - "labels": [], - } - ) - self.assertEqual(status.HTTP_201_CREATED, response.status_code) - - # some other tutor reviews it - self.client.force_authenticate(user=self.data['tutors'][1]) - - response = self.client.post( - '/api/subscription/', { - 'query_type': 'random', - 'feedback_stage': 'feedback-validation' - }) - - self.assertEqual(status.HTTP_201_CREATED, response.status_code) - - subscription_pk = response.data['pk'] - response = self.client.post( - '/api/assignment/', { - 'subscription': subscription_pk - }) - - self.assertEqual(status.HTTP_201_CREATED, response.status_code) - submission_id_in_database = models.Feedback.objects.filter( - is_final=False).first().of_submission.submission_id - submission_id_in_response = response.data['submission']['pk'] - - self.assertEqual( - str(submission_id_in_database), - submission_id_in_response) - - assignment = models.TutorSubmissionAssignment.objects.get( - pk=response.data['pk']) - self.assertFalse(assignment.is_done) - response = self.client.post( - f'/api/assignment/{assignment.pk}/finish/', { - "score": 20, - "is_final": True, - "feedback_lines": { - 2: {"text": "< some addition by second tutor>"}, - } - } - ) - - assignment.refresh_from_db() - meta = assignment.submission.meta - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(2, len(response.data['feedback_lines'][2])) - self.assertTrue(assignment.is_done) - self.assertIn(self.data['tutors'][0], meta.feedback_authors.all()) - self.assertIn(self.data['tutors'][1], meta.feedback_authors.all()) diff --git a/core/tests/test_tutor_api_endpoints.py b/core/tests/test_tutor_api_endpoints.py index 343b649dc81098ba91078b6e571939b029e9c29d..14dc86f3db4648da1414b22dad7e5f8a8667ff9f 100644 --- a/core/tests/test_tutor_api_endpoints.py +++ b/core/tests/test_tutor_api_endpoints.py @@ -12,8 +12,7 @@ from rest_framework.test import (APIClient, APIRequestFactory, APITestCase, force_authenticate) import os -from core.models import (Feedback, SubmissionSubscription, - TutorSubmissionAssignment) +from core.models import Feedback, TutorSubmissionAssignment from core.views import CorrectorApiViewSet from util.factories import GradyUserFactory, make_test_data @@ -100,10 +99,16 @@ class TutorListTests(APITestCase): ) def feedback_cycle(tutor, stage): - subscription = SubmissionSubscription.objects.create( + submissions = TutorSubmissionAssignment.objects.available_assignments({ + 'owner': tutor, + 'stage': stage, + 'submission_type': data['submission_types'][0].pk + }) + assignment = TutorSubmissionAssignment.objects.create( owner=tutor, - feedback_stage=stage) - assignment = subscription.get_or_create_work_assignment() + stage=stage, + submission=submissions.first().submission + ) Feedback.objects.update_or_create( of_submission=assignment.submission, score=35) @@ -113,8 +118,8 @@ class TutorListTests(APITestCase): tutor02 = data['tutors'][1] reviewer = data['reviewers'][0] - feedback_cycle(tutor01, SubmissionSubscription.FEEDBACK_CREATION) - feedback_cycle(tutor02, SubmissionSubscription.FEEDBACK_VALIDATION) + feedback_cycle(tutor01, TutorSubmissionAssignment.FEEDBACK_CREATION) + feedback_cycle(tutor02, TutorSubmissionAssignment.FEEDBACK_VALIDATION) force_authenticate(request, user=reviewer) cls.response = view(request) @@ -130,8 +135,8 @@ class TutorListTests(APITestCase): t = get_user_model().objects.get(username=tutor_obj['username']) feedback_created_count = TutorSubmissionAssignment.objects.filter( is_done=True, - subscription__feedback_stage=SubmissionSubscription.FEEDBACK_CREATION, # noqa - subscription__owner=t + stage=TutorSubmissionAssignment.FEEDBACK_CREATION, # noqa + owner=t ).count() return feedback_created_count == tutor_obj['feedback_created'] @@ -142,8 +147,8 @@ class TutorListTests(APITestCase): t = get_user_model().objects.get(username=tutor_obj['username']) feedback_validated_cnt = TutorSubmissionAssignment.objects.filter( is_done=True, - subscription__feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION, # noqa - subscription__owner=t + stage=TutorSubmissionAssignment.FEEDBACK_VALIDATION, # noqa + owner=t ).count() return feedback_validated_cnt == tutor_obj['feedback_validated'] diff --git a/core/urls.py b/core/urls.py index 66dea3ff2820b65335cb146626d4ac9c7f0287ec..dc918818922b9f6d6bf6bbc178ae0d4835596c21 100644 --- a/core/urls.py +++ b/core/urls.py @@ -18,14 +18,13 @@ router.register('submission', views.SubmissionViewSet, basename='submission') router.register('submissiontype', views.SubmissionTypeApiView) router.register('corrector', views.CorrectorApiViewSet, basename='corrector') -router.register('subscription', views.SubscriptionApiViewSet, - basename='subscription') router.register('assignment', views.AssignmentApiViewSet) router.register('statistics', views.StatisticsEndpoint, basename='statistics') router.register('user', views.UserAccountViewSet, basename='user') router.register('label', views.LabelApiViewSet, basename='label') router.register('label-statistics', views.LabelStatistics, basename='label-statistics') router.register('solution-comment', views.SolutionCommentApiViewSet, basename='solution-comment') +router.register('group', views.GroupApiViewSet, basename='group') schema_view = get_schema_view( openapi.Info( diff --git a/core/views/__init__.py b/core/views/__init__.py index c455fbd93bacba1f16ae9449167d3fda0e16b0bd..1073d2170a1a39dd559cc480b6f11e41921f8f01 100644 --- a/core/views/__init__.py +++ b/core/views/__init__.py @@ -1,6 +1,7 @@ from .feedback import FeedbackApiView, FeedbackCommentApiView # noqa -from .subscription import SubscriptionApiViewSet, AssignmentApiViewSet # noqa +from .assignment import AssignmentApiViewSet # noqa from .common_views import * # noqa from .export import StudentJSONExport, InstanceExport # noqa from .label import LabelApiViewSet, LabelStatistics # noqa from .importer import ImportApiViewSet # noqa +from .group import GroupApiViewSet # noqa diff --git a/core/views/subscription.py b/core/views/assignment.py similarity index 59% rename from core/views/subscription.py rename to core/views/assignment.py index ec052529c209dfc599ead29a237deccb64b90b9f..18669cac65096358050e7904850a0c1fd3e85349 100644 --- a/core/views/subscription.py +++ b/core/views/assignment.py @@ -1,12 +1,11 @@ import logging -from django.core.exceptions import ObjectDoesNotExist from rest_framework import mixins, status, viewsets from rest_framework import decorators from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from core import models, permissions, serializers +from core import models, serializers from core.models import TutorSubmissionAssignment from core.permissions import IsReviewer, IsTutorOrReviewer from core.serializers import AssignmentDetailSerializer, AssignmentSerializer @@ -18,66 +17,11 @@ from core.views.util import tutor_attempts_to_patch_first_feedback_final log = logging.getLogger(__name__) -class SubscriptionApiViewSet( - mixins.RetrieveModelMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): - permission_classes = (permissions.IsTutorOrReviewer,) - serializer_class = serializers.SubscriptionSerializer - queryset = models.SubmissionSubscription.objects\ - .prefetch_related('assignments')\ - .select_related('owner') - - def get_queryset(self): - return self.queryset.filter( - owner=self.request.user, - deactivated=False - ) - - def _get_subscription_if_type_exists(self, data): - try: - return models.SubmissionSubscription.objects.get( - owner=self.request.user, - query_type=data.get('query_type', ''), - query_key=data.get('query_key'), - feedback_stage=data.get( - 'feedback_stage', - models.SubmissionSubscription.FEEDBACK_CREATION) - ) - except ObjectDoesNotExist: - return None - - def create(self, request, *args, **kwargs): - subscription = self._get_subscription_if_type_exists(request.data) - if subscription and not subscription.deactivated: - return Response({'Error': 'Subscriptions have to be unique.'}, - status.HTTP_409_CONFLICT) - elif subscription and subscription.deactivated: - serializer = self.get_serializer(subscription, - data=request.data) - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - subscription = serializer.save() - - if subscription.query_type == models.SubmissionSubscription.STUDENT_QUERY: # noqa - subscription.reserve_all_assignments_for_a_student() - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, - status=status.HTTP_201_CREATED, - headers=headers) - - class AssignmentApiViewSet( mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - queryset = TutorSubmissionAssignment.objects\ - .select_related('subscription').all() + queryset = TutorSubmissionAssignment.objects.all() serializer_class = AssignmentSerializer permission_classes = (IsTutorOrReviewer, ) @@ -85,15 +29,12 @@ class AssignmentApiViewSet( if self.action in ['list', 'active', 'destroy']: return self.queryset.all() else: - return self.queryset.filter(subscription__owner=self.request.user) + return self.queryset.filter(owner=self.request.user) def _fetch_assignment(self, serializer): try: serializer.save() - except models.SubscriptionEnded as err: - return Response({'Error': str(err)}, - status=status.HTTP_410_GONE) - except models.SubscriptionTemporarilyEnded as err: + except models.SubmissionTypeDepleted as err: return Response({'Error': str(err)}, status=status.HTTP_404_NOT_FOUND) except models.NotMoreThanTwoOpenAssignmentsAllowed as err: @@ -115,11 +56,18 @@ class AssignmentApiViewSet( self.get_queryset().filter(is_done=False).delete() return Response(status=status.HTTP_204_NO_CONTENT) + @decorators.action(detail=False, permission_classes=(IsTutorOrReviewer,), methods=['delete']) + def release(self): + self.get_queryset().filter( + is_done=False + ).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + @decorators.action(detail=True, methods=['post']) def finish(self, request, *args, **kwargs): context = self.get_serializer_context() instance = self.get_object() - if instance.is_done or (instance.subscription.owner != request.user): + if instance.is_done or (instance.owner != request.user): return Response(status=status.HTTP_403_FORBIDDEN) try: orig_feedback = instance.submission.feedback @@ -144,15 +92,15 @@ class AssignmentApiViewSet( serializer.save() instance.finish() response_status = status.HTTP_201_CREATED if \ - instance.subscription.feedback_stage == \ - models.SubmissionSubscription.FEEDBACK_CREATION else status.HTTP_200_OK + instance.stage == \ + models.TutorSubmissionAssignment.FEEDBACK_CREATION else status.HTTP_200_OK return Response(serializer.data, status=response_status) def destroy(self, request, pk=None): """ Stop working on the assignment before it is finished """ instance = self.get_object() - if instance.is_done or (instance.subscription.owner != request.user and + if instance.is_done or (instance.owner != request.user and not request.user.is_reviewer()): return Response(status=status.HTTP_403_FORBIDDEN) @@ -162,15 +110,17 @@ class AssignmentApiViewSet( def create(self, request, *args, **kwargs): with Lock(): context = self.get_serializer_context() - serializer = AssignmentDetailSerializer(data=request.data, + data = request.data + serializer = AssignmentDetailSerializer(data=data, context=context) serializer.is_valid(raise_exception=True) assignment = self._fetch_assignment(serializer) + return assignment def retrieve(self, request, *args, **kwargs): assignment = self.get_object() - if assignment.subscription.owner != request.user: + if assignment.owner != request.user: return Response(status=status.HTTP_403_FORBIDDEN) serializer = AssignmentDetailSerializer(assignment) return Response(serializer.data) diff --git a/core/views/common_views.py b/core/views/common_views.py index e9431d38f0adac36152480cf03a6bda8cc3c8012..87fdff6c966acc50484b0604c399dd2260549347 100644 --- a/core/views/common_views.py +++ b/core/views/common_views.py @@ -19,7 +19,8 @@ from rest_framework.response import Response from rest_framework.throttling import AnonRateThrottle from core import models -from core.models import ExamType, StudentInfo, SubmissionType +from core.models import (ExamType, StudentInfo, + SubmissionType, TutorSubmissionAssignment) from core.permissions import IsReviewer, IsStudent, IsTutorOrReviewer from core.serializers import (ExamSerializer, StudentInfoSerializer, StudentInfoForListViewSerializer, @@ -109,8 +110,7 @@ class CorrectorApiViewSet( permission_classes = (IsReviewer,) queryset = models.UserAccount.corrector \ .with_feedback_count() \ - .prefetch_related('subscriptions') \ - .prefetch_related('subscriptions__assignments') + .prefetch_related('assignments') serializer_class = CorrectorSerializer @action(detail=False, methods=['post'], permission_classes=[AllowAny]) @@ -131,6 +131,30 @@ class SubmissionTypeApiView(viewsets.ReadOnlyModelViewSet): serializer_class = SubmissionTypeSerializer permission_classes = (IsTutorOrReviewer, ) + @action(detail=False) + def available(self, request, *args, **kwargs): + sub_types = self.get_queryset() + # TODO maybe do this as a non detail view on assignment instead which is passed + # a create assignment dict + res = {} + for sub_type in sub_types: + counts = {} + for stage, _ in models.TutorSubmissionAssignment.stages: + counts_for_group = {} + for group in models.Group.objects.all(): + count_in_stage_for_group = \ + TutorSubmissionAssignment.objects.available_assignments({ + 'stage': stage, + 'submission_type': sub_type.pk, + 'owner': self.request.user, + 'group': group.pk + }).count() + counts_for_group[str(group.pk)] = count_in_stage_for_group + counts[stage] = counts_for_group + res[str(sub_type.pk)] = counts + + return Response(res) + class SolutionCommentApiViewSet( mixins.CreateModelMixin, @@ -210,7 +234,7 @@ class SubmissionViewSet(viewsets.ReadOnlyModelViewSet): ) else: return self.queryset.filter( - assignments__subscription__owner=self.request.user + assignments__owner=self.request.user ) @action(detail=True,) diff --git a/core/views/feedback.py b/core/views/feedback.py index a1107208f416e2c5d2bf39183c41317cec6b45f6..127f39488ed26fd8e1263a0c8932dbece670f128 100644 --- a/core/views/feedback.py +++ b/core/views/feedback.py @@ -48,7 +48,7 @@ class FeedbackApiView( .all() return self.queryset.filter( - of_submission__assignments__subscription__owner=self.request.user + of_submission__assignments__owner=self.request.user ) @decorators.permission_classes((permissions.IsReviewer,)) diff --git a/core/views/group.py b/core/views/group.py new file mode 100644 index 0000000000000000000000000000000000000000..e083f26d82bd02673e4a2028c377cb7b19e716a5 --- /dev/null +++ b/core/views/group.py @@ -0,0 +1,14 @@ +import logging + +from rest_framework import mixins, viewsets + +from core import models, permissions, serializers + +log = logging.getLogger(__name__) + + +class GroupApiViewSet(viewsets.GenericViewSet, + mixins.ListModelMixin): + permission_classes = (permissions.IsTutorOrReviewer, ) + queryset = models.Group.objects.all() + serializer_class = serializers.GroupSerializer diff --git a/core/views/util.py b/core/views/util.py index b03018738c2767f49c46cb3f0d32cf5adf61da5b..8768f46498cd1a267907ead475f49578c12fd610 100644 --- a/core/views/util.py +++ b/core/views/util.py @@ -15,7 +15,7 @@ def tutor_attempts_to_patch_first_feedback_final(feedback_serializer, if user.role == models.UserAccount.TUTOR and assignment is None: raise NoAssignmentForTutor() is_final_set = feedback_serializer.validated_data.get('is_final', False) - in_creation = assignment.subscription.feedback_stage == models.SubmissionSubscription.FEEDBACK_CREATION # noqa + in_creation = assignment.stage == models.TutorSubmissionAssignment.FEEDBACK_CREATION # noqa return is_final_set and in_creation @@ -23,7 +23,7 @@ def get_implicit_assignment_for_user(submission, user): """ Check for tutor if it exists. Not relevant for reviewer """ try: return models.TutorSubmissionAssignment.objects.get( - subscription__owner=user, + owner=user, submission=submission ) except models.TutorSubmissionAssignment.DoesNotExist: diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 17ca6787bdccc5decf711801da56e8d6378d2fa6..a06de7c46f5ad7f56406a11dd36cf2aff50fd046 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -10,11 +10,13 @@ import { StudentInfoForListView, Submission, SubmissionNoType, SubmissionType, - Subscription, Tutor, UserAccount, LabelStatisticsForSubType, FeedbackLabel, SolutionComment, - CreateUpdateFeedback + CreateUpdateFeedback, + AvailableSubmissionCounts, + Group } from '@/models' +import { CreateAssignment } from './models' function getInstanceBaseUrl (): string { if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') { @@ -84,19 +86,6 @@ export async function fetchAllTutors (): Promise<Array<Tutor>> { return (await ax.get(url)).data } -export async function fetchSubscriptions (): Promise<Array<Subscription>> { - return (await ax.get('/api/subscription/')).data -} - -export async function deactivateSubscription ({ pk }: {pk: string}): Promise<AxiosResponse<void>> { - const url = `/api/subscription/${pk}/` - return ax.delete(url) -} - -export async function fetchSubscription (subscriptionPk: string): Promise<Subscription> { - return (await ax.get(`/api/subscription/${subscriptionPk}/`)).data -} - export async function fetchAllFeedback (): Promise<Array<Feedback>> { const url = '/api/feedback/' return (await ax.get(url)).data @@ -122,35 +111,8 @@ export async function fetchLabelStatistics (): Promise<LabelStatisticsForSubType return (await ax.get(url)).data } -interface SubscriptionCreatePayload { - queryType: Subscription.QueryTypeEnum - queryKey?: string - feedbackStage?: Subscription.FeedbackStageEnum -} - -export async function subscribeTo (type: Subscription.QueryTypeEnum, - key?: string, - stage?: Subscription.FeedbackStageEnum): Promise<Subscription> { - let data: SubscriptionCreatePayload = { - queryType: type - } - - if (key) { - data.queryKey = key - } - if (stage) { - data.feedbackStage = stage - } - - return (await ax.post('/api/subscription/', data)).data -} -export async function createAssignment ( - { subscription = undefined, subscriptionPk = '' }: - {subscription?: Subscription, subscriptionPk?: string}): Promise<Assignment> { - const data = { - subscription: subscription ? subscription.pk : subscriptionPk - } +export async function createAssignment (data: CreateAssignment): Promise<Assignment> { return (await ax.post('/api/assignment/', data)).data } @@ -174,7 +136,17 @@ export async function fetchSubmissionTypes (): Promise<Array<SubmissionType>> { } export async function fetchSubmissionType (pk: string): Promise<SubmissionType> { - const url = `/api/submissiontype/${pk}` + const url = `/api/submissiontype/${pk}/` + return (await ax.get(url)).data +} + +export async function fetchAvailableSubmissionCounts(): Promise<AvailableSubmissionCounts> { + const url = '/api/submissiontype/available/' + return (await ax.get(url)).data +} + +export async function fetchGroups(): Promise<Group[]> { + const url = '/api/group/' return (await ax.get(url)).data } @@ -250,6 +222,10 @@ export async function updateLabel (payload: FeedbackLabel) { return (await ax.put('/api/label/' + payload.pk + '/', payload)).data } +export async function fetchSubmissionCounts () { + return (await ax.get('/api/submissiontype/available_counts/')).data +} + export interface StudentExportOptions { setPasswords?: boolean } export interface StudentExportItem { Matrikel: string, diff --git a/frontend/src/components/feedback_list/FeedbackSearchOptions.vue b/frontend/src/components/feedback_list/FeedbackSearchOptions.vue index 19601cccdce4d961a558367f79faaf2faf2e90a4..9caa968d9c6b2d1284899f7c585df033ea793e50 100644 --- a/frontend/src/components/feedback_list/FeedbackSearchOptions.vue +++ b/frontend/src/components/feedback_list/FeedbackSearchOptions.vue @@ -8,7 +8,9 @@ lg3 > <v-checkbox + id="show-final-checkbox" v-model="showFinal" + :ripple="false" label="show final" /> </v-flex> diff --git a/frontend/src/components/feedback_list/FeedbackTable.vue b/frontend/src/components/feedback_list/FeedbackTable.vue index e3030c819c261f0cccd275dbd7e811b9701a9941..0c56c50e6d81d14a18de79e8105356aa3e263d52 100644 --- a/frontend/src/components/feedback_list/FeedbackTable.vue +++ b/frontend/src/components/feedback_list/FeedbackTable.vue @@ -53,7 +53,7 @@ import { getObjectValueByPath } from '@/util/helpers' import FeedbackSearchOptions from '@/components/feedback_list/FeedbackSearchOptions.vue' import { FeedbackSearchOptions as OptionsModule } from '@/store/modules/feedback_list/feedback-search-options' import { FeedbackTable as FeedbackModule, FeedbackHistoryItem } from '@/store/modules/feedback_list/feedback-table' -import { Subscription, Feedback } from '@/models' +import { FeedbackStageEnum, Feedback } from '@/models' import { actions } from '@/store/actions' import { getters } from '@/store/getters' import { Authentication } from '../../store/modules/authentication' @@ -112,8 +112,8 @@ export default class FeedbackTable extends Vue { } const associatedTutors = this.stageFilterString === 'all' ? Object.values(feedback.history).filter(histEntry => !!histEntry).map(histEntry => histEntry!.owner) - : feedback.history[this.stageFilterString as Subscription.FeedbackStageEnum] - ? [feedback.history[this.stageFilterString as Subscription.FeedbackStageEnum]!.owner] : [] + : feedback.history[this.stageFilterString as FeedbackStageEnum] + ? [feedback.history[this.stageFilterString as FeedbackStageEnum]!.owner] : [] return this.filterByTutors.length === 0 || associatedTutors.some(tutor => this.filterByTutors.includes(tutor)) diff --git a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue index 3e9e3c967d65ed57ce747a1a5674203421849110..3ea53edba364721f917bc2930e6a9db4bc8bbbe4 100644 --- a/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue +++ b/frontend/src/components/submission_notes/toolbars/AnnotatedSubmissionBottomToolbar.vue @@ -132,7 +132,7 @@ <script> import { SubmissionNotes } from '@/store/modules/submission-notes' import { Authentication } from '@/store/modules/authentication' -import { Subscriptions } from '@/store/modules/subscriptions' +import { Assignments } from '@/store/modules/assignments' export default { name: 'AnnotatedSubmissionBottomToolbar', @@ -183,7 +183,7 @@ export default { }, methods: { initialFinalStatus () { - if (this.$route.name === 'subscription') { + if (this.$route.name === 'correction') { return !SubmissionNotes.isFeedbackCreation || Authentication.isReviewer } else { if (this.feedback.hasOwnProperty('isFinal')) { @@ -214,7 +214,7 @@ export default { }, skipSubmission () { if (this.skippable) { - Subscriptions.skipAssignment().catch(() => { + Assignments.skipAssignment().catch(() => { this.$notify({ title: 'Unable to skip submission', type: 'error' diff --git a/frontend/src/components/subscriptions/SubscriptionCreation.vue b/frontend/src/components/subscriptions/SubscriptionCreation.vue deleted file mode 100644 index 7e039d0efb5aed7c1cd05cd090c2ab6ccbf81206..0000000000000000000000000000000000000000 --- a/frontend/src/components/subscriptions/SubscriptionCreation.vue +++ /dev/null @@ -1,111 +0,0 @@ -<template> - <v-card> - <v-card-text> - <v-card-title> - <h3>Subscribe to {{ title }}</h3> - </v-card-title> - <v-select - v-if="keyItems" - v-model="key" - :items="keyItems" - label="Select your desired type" - /> - <v-select - v-model="stage" - return-object - :items="possibleStages" - label="Select your desired feedback stage" - /> - <v-card-actions> - <v-spacer /> - <v-btn - flat - :loading="loading" - @click="subscribe" - > - Subscribe - </v-btn> - </v-card-actions> - </v-card-text> - </v-card> -</template> - -<script> -import { Authentication } from '@/store/modules/authentication' -import { Subscriptions } from '@/store/modules/subscriptions' - -const stages = [ - { - text: 'Initial Feedback', - stage: 'feedback-creation' - }, - { - text: 'Feedback validation', - stage: 'feedback-validation' - } -] - -export default { - name: 'SubscriptionCreation', - props: { - title: { - type: String, - required: true - }, - type: { - type: String, - required: true - }, - keyItems: { - default: () => [], - type: Array - } - }, - data () { - return { - key: { - text: '', - key: '' - }, - stage: stages[0], - loading: false - } - }, - computed: { - possibleStages () { - let possibleStages = [...stages] - if (Authentication.isReviewer) { - possibleStages.push({ - text: 'Conflict resolution', - stage: 'feedback-conflict-resolution' - }) - } - return possibleStages - } - }, - methods: { - subscribe () { - this.loading = true - Subscriptions.subscribeTo({ - type: this.type, - key: this.key.key, - stage: this.stage.stage - }).catch(err => { - if (err.response && err.response.data['non_field_errors']) { - this.$notify({ - title: 'Couldn\'t create subscription', - text: err.response.data['non_field_errors'].join(' '), - type: 'error' - }) - } - }).finally(() => { - this.loading = false - }) - } - } -} -</script> - -<style scoped> - -</style> diff --git a/frontend/src/components/subscriptions/SubscriptionEnded.vue b/frontend/src/components/subscriptions/SubscriptionEnded.vue index 7890a5dcc34545de6e7b43c20d47dfe00be2d905..51f5311d1332ab848d91d3a6d0fcad034c65cf64 100644 --- a/frontend/src/components/subscriptions/SubscriptionEnded.vue +++ b/frontend/src/components/subscriptions/SubscriptionEnded.vue @@ -7,9 +7,9 @@ No submissions left </v-card-title> <v-card-text> - All submissions for <b> {{ submissionTypeName() }} </b> in the current stage have been corrected. If you've - been validating feedback or <br> - resolving conflicts some submissions may become active again. + All submissions of this type in the current stage have been corrected. If you've + been validating feedback or <br> + reviewing, new submissions might be available in the future. If that is the case they will appear clickable in the sidebar again. </v-card-text> <v-card-actions class="text-xs-center"> @@ -31,11 +31,6 @@ import store from '@/store/store' @Component export default class SubscriptionEnded extends Vue { - get submissionTypes () { return store.state.submissionTypes } - - submissionTypeName () { - return this.submissionTypes[this.$route.params.typePk].name - } } </script> diff --git a/frontend/src/components/subscriptions/SubscriptionForList.vue b/frontend/src/components/subscriptions/SubscriptionForList.vue index c38a974f818490d95d4b34773e07e93691690d00..73d5d45c1b00d4e97b3ebe664a4bb181b1841aab 100644 --- a/frontend/src/components/subscriptions/SubscriptionForList.vue +++ b/frontend/src/components/subscriptions/SubscriptionForList.vue @@ -2,7 +2,7 @@ <v-layout row> <v-list-tile exact - :to="subscriptionRoute" + :to="correctionRoute" style="width: 100%" > <!-- dynamically set css class depending on active --> @@ -23,30 +23,44 @@ import Vue from 'vue' import Component from 'vue-class-component' import { Prop } from 'vue-property-decorator' -import { Assignment, Subscription } from '@/models' -import { Subscriptions } from '@/store/modules/subscriptions' +import { Assignment , FeedbackStageEnum } from '@/models' +import { Assignments } from '@/store/modules/assignments' @Component export default class SubscriptionForList extends Vue { - @Prop({ type: String, required: true }) pk!: string - @Prop({ type: String, required: true }) feedbackStage!: Subscription.FeedbackStageEnum - @Prop({ type: String, required: true }) queryType!: Subscription.QueryTypeEnum - @Prop({ type: Number, required: true }) available!: number - @Prop({ type: Array, required: true }) assignments!: Assignment[] - @Prop({ type: String, default: '' }) queryKey!: string + @Prop({ type: String, required: true }) name!: string + @Prop({ type: String, required: true }) sub_type_pk!: string - get name () { - return Subscriptions.resolveSubscriptionKeyToName( - { queryKey: this.queryKey, queryType: this.queryType }) - } get active () { - return !!this.available || this.assignments.length > 0 + return !!this.available + } + + get available () { + const stage = Assignments.state.assignmentCreation.stage + const group = Assignments.state.assignmentCreation.group + const group_pk = group !== undefined ? group.pk : undefined + if (group_pk === undefined) { + return 0 + } + const sub_type = this.sub_type_pk + const forSubType = Assignments.state.submissionsLeft[sub_type] + const forStage = forSubType !== undefined ? forSubType[stage] : undefined + const forGroup = forStage !== undefined ? forStage[group_pk] : 0 + return forGroup } - get subscriptionRoute () { - if (this.active) { - return { name: 'subscription', params: { pk: this.pk } } + + get correctionRoute() { + const group = Assignments.state.assignmentCreation.group + const group_pk = group !== undefined ? group.pk : undefined + + return { + name: 'correction', + params: { + sub_type: this.sub_type_pk, + stage: Assignments.state.assignmentCreation.stage, + group: group_pk || undefined, + } } - return this.$route.fullPath } } </script> diff --git a/frontend/src/components/subscriptions/SubscriptionList.vue b/frontend/src/components/subscriptions/SubscriptionList.vue index bb7e561ce37a860ad357656051a4b629b9444da9..893120b9ee7cd61664c4aa1b760b940471d56743 100644 --- a/frontend/src/components/subscriptions/SubscriptionList.vue +++ b/frontend/src/components/subscriptions/SubscriptionList.vue @@ -6,15 +6,21 @@ > <v-toolbar-side-icon><v-icon>assignment</v-icon></v-toolbar-side-icon> <v-toolbar-title - v-if="showDetail" + v-if="!sidebar" style="min-width: fit-content;" > Tasks </v-toolbar-title> <v-spacer /> + <v-select + :items="groups" + v-model="selectedGroup" + item-text="name" + return-object + /> <v-btn icon - @click="getSubscriptions(false)" + @click="getAvailableSubmissionCount(false)" > <v-icon v-if="!updating"> refresh @@ -55,44 +61,81 @@ <script lang="ts"> import Vue from 'vue' import Component from 'vue-class-component' -import { Prop } from 'vue-property-decorator' +import { Prop, Watch } from 'vue-property-decorator' import { mapGetters, mapActions, mapState } from 'vuex' import { UI } from '@/store/modules/ui' import { actions } from '@/store/actions' -import SubscriptionCreation from '@/components/subscriptions/SubscriptionCreation.vue' import SubscriptionForList from '@/components/subscriptions/SubscriptionForList.vue' import SubscriptionsForStage from '@/components/subscriptions/SubscriptionsForStage.vue' -import { Subscriptions } from '@/store/modules/subscriptions' +import { Assignments } from '@/store/modules/assignments' +import store from '../../store/store' +import { FeedbackStageEnum, Group } from '../../models' +import { Authentication } from '../../store/modules/authentication' @Component({ name: 'subscription-list', components: { SubscriptionsForStage, SubscriptionForList, - SubscriptionCreation }, }) export default class SubscriptionList extends Vue { @Prop({type: Boolean, default: false}) sidebar!: boolean - selectedStage = null updating = false timer = 0 - get subscriptions () { return Subscriptions.state.subscriptions } - get stages () { return Subscriptions.availableStages } - get stagesReadable () { return Subscriptions.availableStagesReadable } + get stages () { return Assignments.availableStages } + get stagesReadable () { return Assignments.availableStagesReadable } get showDetail () { return !this.sidebar || (this.sidebar && !UI.state.sideBarCollapsed) } + get groups () { + return Assignments.state.groups + } + + get selectedStage() { + const val = Assignments.state.assignmentCreation.stage + switch (val) { + case FeedbackStageEnum.Creation: return 0 + case FeedbackStageEnum.Validation: return 1 + case FeedbackStageEnum.Review: return 2 + default: + throw new Error(`Illegal value ${val} in get selectedStage`) + } + } + + set selectedStage(val) { + const map_number_to_stage = (val: number): FeedbackStageEnum => { + switch (val) { + case 0: return FeedbackStageEnum.Creation + case 1: return FeedbackStageEnum.Validation + case 2: return FeedbackStageEnum.Review + default: + throw new Error(`Illegal value ${val} in set selectedStage`) + } + } + Assignments.SET_CREATE_STAGE(map_number_to_stage(val)) + } + + get selectedGroup() { + return Assignments.state.assignmentCreation.group + } - async getSubscriptions (silent: boolean) { + set selectedGroup(val: Group | undefined) { + if (val === undefined) { + throw new Error('Setting create group to undefined is not allowed') + } + Assignments.SET_CREATE_GROUP(val) + } + + + async getAvailableSubmissionCount (silent: boolean) { if (silent === false) { this.updating = true } - const subscriptions = await Subscriptions.getSubscriptions() + await Assignments.getAvailableSubmissionCounts() this.updating = false - return subscriptions } beforeDestroy() { @@ -100,18 +143,26 @@ export default class SubscriptionList extends Vue { } created() { + const ownGroup = Authentication.state.user.exerciseGroups[0] + this.selectedGroup = ownGroup + const submissionTypes = actions.updateSubmissionTypes() - const subscriptions = Subscriptions.getSubscriptions() + const groups = Assignments.getGroups() + Promise.all([submissionTypes, groups]).then(() => { + this.getAvailableSubmissionCount(false) + + this.timer = setInterval(() => { + this.getAvailableSubmissionCount(true) + }, 30 * 1e3) + }) - this.timer = setInterval(() => { - this.getSubscriptions(true) - }, 30 * 1e3) - Promise.all([submissionTypes, subscriptions]).then(() => { - Subscriptions.subscribeToAll() - Subscriptions.cleanAssignmentsFromSubscriptions(true) + Promise.all([submissionTypes]).then(() => { + Assignments.cleanAssignments() }) } + + } </script> diff --git a/frontend/src/components/subscriptions/SubscriptionsForStage.vue b/frontend/src/components/subscriptions/SubscriptionsForStage.vue index 49587104479ae0cae9977fbce0f3322779a54163..6e7c7dda486ab2f4c96101b10ae49d005dea9e69 100644 --- a/frontend/src/components/subscriptions/SubscriptionsForStage.vue +++ b/frontend/src/components/subscriptions/SubscriptionsForStage.vue @@ -2,11 +2,13 @@ <v-list :dense="dense"> <div> <div - v-for="subscription in subscriptions['submission_type']" - :key="subscription.pk" + v-for="subType in submissionTypes" + :key="subType.pk" > <subscription-for-list - v-bind="subscription" + :name="subType.name" + :sub_type_pk="subType.pk" + :available="1" /> </div> </div> @@ -15,7 +17,8 @@ <script> import SubscriptionForList from '@/components/subscriptions/SubscriptionForList' -import { Subscriptions } from '@/store/modules/subscriptions' +import { Assignments } from '@/store/modules/assignments' +import store from '../../store/store' export default { name: 'SubscriptionsForStage', components: { @@ -32,9 +35,7 @@ export default { } }, computed: { - subscriptions () { - return Subscriptions.getSubscriptionsGroupedByType[this.stage] - } + submissionTypes () { return store.state.submissionTypes } } } </script> diff --git a/frontend/src/models.ts b/frontend/src/models.ts index 359a75d6264370c8c8a2bd52a6e401a2cf7495e0..1982c228151576f7130b6f0912fb87e7b612a685 100644 --- a/frontend/src/models.ts +++ b/frontend/src/models.ts @@ -1,3 +1,14 @@ +export interface Group { + pk: string, + name: string +} + +export interface CreateAssignment { + submissionType: string + group?: string + stage: FeedbackStageEnum +} + /** * * @export @@ -33,11 +44,9 @@ export interface Assignment { * @type {string} * @memberof Assignment */ - stage?: Subscription.FeedbackStageEnum + stage?: FeedbackStageEnum feedback?: Feedback - - subscription?: string } export interface SubmissionAssignment { @@ -604,6 +613,14 @@ export interface SubmissionType { solutionComments: {[ofLine: number]: SolutionComment[]} } +export interface AvailableSubmissionCounts { + [index: string]: { + [index: string]: { + [index: string]: number + } + } +} + export interface SolutionComment { pk: number, created: string, @@ -664,92 +681,15 @@ export interface SubmissionTypeProgress { submissionCount: number } -/** - * - * @export - * @interface Subscription - */ -export interface Subscription { - /** - * - * @type {string} - * @memberof Subscription - */ - pk: string - /** - * - * @type {string} - * @memberof Subscription - */ - owner?: string - /** - * - * @type {string} - * @memberof Subscription - */ - queryType?: Subscription.QueryTypeEnum - /** - * - * @type {string} - * @memberof Subscription - */ - queryKey?: string - /** - * - * @type {string} - * @memberof Subscription - */ - feedbackStage?: Subscription.FeedbackStageEnum - /** - * - * @type {boolean} - * @memberof Subscription - */ - deactivated?: boolean - /** - * - * @type {string} - * @memberof Subscription - */ - assignments?: Array<Assignment> - /** - * - * @type {string} - * @memberof Subscription - */ - remaining?: string - /** - * - * @type {string} - * @memberof Subscription - */ - available?: string -} /** * @export - * @namespace Subscription + * @enum {string} */ -export namespace Subscription { - /** - * @export - * @enum {string} - */ - export enum QueryTypeEnum { - Random = 'random', - Student = 'student', - Exam = 'exam', - SubmissionType = 'submission_type' - } - /** - * @export - * @enum {string} - */ - export enum FeedbackStageEnum { - Creation = 'feedback-creation', - Validation = 'feedback-validation', - ConflictResolution = 'feedback-conflict-resolution' - } +export enum FeedbackStageEnum { + Creation = 'feedback-creation', + Validation = 'feedback-validation', + Review = 'feedback-review' } /** @@ -864,6 +804,7 @@ export interface UserAccount { * @memberof UserAccount */ password?: string + exerciseGroups: Group[] } /** diff --git a/frontend/src/pages/SubscriptionWorkPage.vue b/frontend/src/pages/SubscriptionWorkPage.vue index 2ba14b0bce50c2029839f7acf4b4a792b6d71c8e..81cf9bff2069615050e95fcceed3a6733d4f73f4 100644 --- a/frontend/src/pages/SubscriptionWorkPage.vue +++ b/frontend/src/pages/SubscriptionWorkPage.vue @@ -9,7 +9,7 @@ md6 > <submission-correction - :key="subscription.pk" + :key="currentAssignment.pk" :assignment="currentAssignment" class="ma-4 autofocus" @feedbackCreated="startWorkOnNextAssignment" @@ -44,13 +44,16 @@ import SubmissionType from '@/components/submission_type/SubmissionType.vue' import store from '@/store/store' import { SubmissionNotes } from '@/store/modules/submission-notes' import SubmissionTests from '@/components/SubmissionTests.vue' -import { Subscriptions } from '@/store/modules/subscriptions' +import { Assignments } from '@/store/modules/assignments' import RouteChangeConfirmation from '@/components/submission_notes/RouteChangeConfirmation.vue' import { getters } from '@/store/getters' import { SubmissionAssignment } from '@/models' const onRouteEnterOrUpdate: NavigationGuard = function (to, from, next) { - Subscriptions.changeToSubscription(to.params['pk']).then(() => { + Assignments.changeAssignment(to).then(() => { + if (from === to) { + return + } next() }) } @@ -68,12 +71,8 @@ export default class SubscriptionWorkPage extends Vue { subscriptionActive = false nextRoute = () => {} - get subscription () { - return Subscriptions.state.subscriptions[this.$route.params['pk']] - } - get currentAssignment () { - return Subscriptions.state.currentAssignment + return Assignments.state.currentAssignment } get submission () { @@ -97,22 +96,22 @@ export default class SubscriptionWorkPage extends Vue { } beforeRouteLeave (this: SubscriptionWorkPage, to: Route, from: Route, next: (to?: any) => void) { - if (to.name === 'subscription-ended') { + if (to.name === 'correction-ended') { next() } else { this.nextRoute = () => { next() - Subscriptions.deleteCurrentAssignment() + Assignments.deleteCurrentAssignment() } } } startWorkOnNextAssignment () { - Subscriptions.createNextAssignment().catch(() => { - const typePk = SubmissionNotes.state.submission.type - this.$router.replace(typePk + '/ended') - Subscriptions.SET_CURRENT_ASSIGNMENT(undefined) - Subscriptions.getSubscriptions() + Assignments.createNextAssignment().then(() => { + Assignments.getAvailableSubmissionCounts() + }).catch(() => { + Assignments.SET_CURRENT_ASSIGNMENT(undefined) + this.$router.replace({name: 'correction-ended'}) }) } } diff --git a/frontend/src/pages/reviewer/ReviewerStartPage.vue b/frontend/src/pages/reviewer/ReviewerStartPage.vue index 39df51b43cd8237a5b27cee009b866abe746a3ee..974ebc04997b3ec174b5ba895a009e37affea3a5 100644 --- a/frontend/src/pages/reviewer/ReviewerStartPage.vue +++ b/frontend/src/pages/reviewer/ReviewerStartPage.vue @@ -57,7 +57,7 @@ import ImportDialog from '@/components/ImportDialog' import SubscriptionList from '@/components/subscriptions/SubscriptionList' import SubmissionTypesOverview from '@/components/submission_type/SubmissionTypesOverview' import { getters } from '../../store/getters' -import { Subscriptions } from '../../store/modules/subscriptions' +import { Assignments } from '@/store/modules/assignments' export default { name: 'ReviewerStartPage', @@ -81,7 +81,7 @@ export default { methods: { importDone() { this.dataImported = true - Subscriptions.RESET_STATE() + Assignments.RESET_STATE() } } } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2d57d382fcfe4d0670e162da0eafd24732b1bb60..74182743aa302bd1ace32187f49c47727bf82d4a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -82,14 +82,14 @@ const router = new Router({ component: StartPageSelector }, { - path: 'subscription/:pk', - name: 'subscription', + path: 'correction/:sub_type/:stage/:group?', + name: 'correction', beforeEnter: tutorOrReviewerOnly, component: SubscriptionWorkPage }, { - path: 'subscription/:typePk/ended', - name: 'subscription-ended', + path: 'correction/ended', + name: 'correction-ended', component: SubscriptionEnded }, { diff --git a/frontend/src/store/actions.ts b/frontend/src/store/actions.ts index 459424280ae43dddc4dc9b3d45171529f6a6d042..831b7fc9460e6f4627cd43d8eaeaebc910a8fa69 100644 --- a/frontend/src/store/actions.ts +++ b/frontend/src/store/actions.ts @@ -8,7 +8,7 @@ import * as api from '@/api' import router from '@/router/index' import { RootState } from '@/store/store' import { FeedbackTable } from '@/store/modules/feedback_list/feedback-table' -import { Subscriptions } from '@/store/modules/subscriptions' +import { Assignments } from '@/store/modules/assignments' import { TutorOverview } from './modules/tutor-overview' import { StudentPage } from './modules/student-page' @@ -65,7 +65,7 @@ async function getStatistics () { function resetState ({ message }: {message: string}) { FeedbackTable.RESET_STATE() - Subscriptions.RESET_STATE() + Assignments.RESET_STATE() SubmissionNotes.RESET_STATE() StudentPage.RESET_STATE() Authentication.RESET_STATE() diff --git a/frontend/src/store/modules/assignments.ts b/frontend/src/store/modules/assignments.ts new file mode 100644 index 0000000000000000000000000000000000000000..853d8520ee685bc583220c3c007dc10e5e8c3bc0 --- /dev/null +++ b/frontend/src/store/modules/assignments.ts @@ -0,0 +1,194 @@ +import Vue from 'vue' +import * as api from '@/api' +import { cartesian, flatten, once } from '@/util/helpers' +import { Assignment, FeedbackStageEnum, CreateAssignment, AvailableSubmissionCounts, Group} from '@/models' +import { RootState } from '@/store/store' +import { Authentication } from '@/store/modules/authentication' +import { getStoreBuilder, BareActionContext } from 'vuex-typex' +import router from '@/router' +import { Route } from 'vue-router' + +export interface AssignmentsState { + currentAssignment?: Assignment + assignmentCreation: { + submissionType?: string + stage: FeedbackStageEnum + group?: Group + }, + submissionsLeft: AvailableSubmissionCounts, + groups: Group[], + loading: boolean +} + +function initialState (): AssignmentsState { + return { + currentAssignment: undefined, + loading: false, + assignmentCreation: { + stage: FeedbackStageEnum.Creation, + group: undefined, + submissionType: undefined + }, + submissionsLeft: {}, + groups: [] + } +} + +const mb = getStoreBuilder<RootState>().module('Assignments', initialState()) + +const stateGetter = mb.state() + + +const availableStagesGetter = mb.read(function availableStages (state, getters) { + let stages = [FeedbackStageEnum.Creation, FeedbackStageEnum.Validation] + if (Authentication.isReviewer) { + stages.push(FeedbackStageEnum.Review) + } + return stages +}) + +const availableStagesReadableGetter = mb.read(function availableStagesReadable (state, getters) { + let stages = ['initial', 'validate'] + if (Authentication.isReviewer) { + stages.push('review') + } + return stages +}) + +const availableSubmissionTypeQueryKeysGetter = mb.read(function availableSubmissionTypeQueryKeys (state, getters, rootState) { + return Object.values(rootState.submissionTypes).map((subType: any) => subType.pk) +}) + +const availableExamTypeQueryKeysGetter = mb.read(function availableExamTypeQueryKeys (state, getters, rootState) { + return Object.values(rootState.examTypes).map((examType: any) => examType.pk) +}) + + +function SET_CURRENT_ASSIGNMENT (state: AssignmentsState, assignment?: Assignment): void { + state.currentAssignment = assignment +} + +function SET_CREATE_SUBMISSION_TYPE (state: AssignmentsState, submissionType: string): void { + state.assignmentCreation.submissionType = submissionType +} + +function SET_CREATE_STAGE (state: AssignmentsState, stage: FeedbackStageEnum): void { + state.assignmentCreation.stage = stage +} + +function SET_CREATE_GROUP (state: AssignmentsState, group: Group): void { + state.assignmentCreation.group = group +} + +function SET_SUBMISSION_LEFT (state: AssignmentsState, availableSubmissions: AvailableSubmissionCounts): void { + state.submissionsLeft = availableSubmissions +} + +function SET_GROUPS (state: AssignmentsState, groups: Group[]): void { + state.groups = groups +} + +function UPDATE_CREATE_PARAMETERS_FROM_URL(state: AssignmentsState, route: Route) { + const submissionType = route.params['sub_type'] + const stage = route.params['stage'] as FeedbackStageEnum + const group_par = route.params['group'] + + state.assignmentCreation.submissionType = submissionType + state.assignmentCreation.stage = stage + const group = state.groups.find((group) => group.pk === group_par) + if (group === undefined) { + console.log(state.groups) + throw new Error(`Group ${group_par} appeared in parameter but not available in ${state.groups}`) + } + state.assignmentCreation.group = group +} + +function RESET_STATE (state: AssignmentsState): void { + Object.assign(state, initialState()) +} + +async function createNextAssignment({ state }: BareActionContext<AssignmentsState, RootState>) { + const createAssignment = state.assignmentCreation + if (createAssignment.submissionType === undefined ) { + throw new Error('SET_CREATE_SUBMISSION_TYPE needs to be called before createNextAssignment') + } + + const data = { + stage: createAssignment.stage, + submissionType: createAssignment.submissionType!, + group: createAssignment.group !== undefined ? createAssignment.group.pk : undefined + } + + Assignments.SET_CURRENT_ASSIGNMENT(await api.createAssignment(data)) +} + +async function cleanAssignments +({ state }: BareActionContext<AssignmentsState, RootState>) { + console.log('TODO cleanAssignments') +} + +async function changeAssignment +({ state }: BareActionContext<AssignmentsState, RootState>, route: Route) { + Assignments.UPDATE_CREATE_PARAMETERS_FROM_URL(route) + if (state.currentAssignment) { + await Assignments.deleteCurrentAssignment() + } + await Assignments.createNextAssignment() +} + +async function skipAssignment ({ state }: BareActionContext<AssignmentsState, RootState>) { + if (!state.currentAssignment) { + throw new Error('skipAssignment can only be called with active assignment') + } + + const oldAssignment = state.currentAssignment + await Assignments.createNextAssignment() + await api.deleteAssignment({assignment: oldAssignment }) + +} + +async function deleteCurrentAssignment ({ state }: BareActionContext<AssignmentsState + , RootState>) { + if (!state.currentAssignment) { + throw new Error('No active assignment to delete') + } + await api.deleteAssignment({assignment: state.currentAssignment}) + Assignments.SET_CURRENT_ASSIGNMENT(undefined) +} + +async function getAvailableSubmissionCounts() { + const counts = await api.fetchAvailableSubmissionCounts() + Assignments.SET_SUBMISSION_LEFT(counts) +} + +async function getGroups() { + const groups = await api.fetchGroups() + Assignments.SET_GROUPS(groups) +} + + +export const Assignments = { + get state () { return stateGetter() }, + get availableStages () { return availableStagesGetter() }, + get availableStagesReadable () { return availableStagesReadableGetter() }, + get availableSubmissionTypeQueryKeys () { return availableSubmissionTypeQueryKeysGetter() }, + get availableExamTypeQueryKeys () { return availableExamTypeQueryKeysGetter() }, + + SET_CURRENT_ASSIGNMENT: mb.commit(SET_CURRENT_ASSIGNMENT), + SET_CREATE_SUBMISSION_TYPE: mb.commit(SET_CREATE_SUBMISSION_TYPE), + SET_CREATE_STAGE: mb.commit(SET_CREATE_STAGE), + SET_CREATE_GROUP: mb.commit(SET_CREATE_GROUP), + SET_SUBMISSION_LEFT: mb.commit(SET_SUBMISSION_LEFT), + SET_GROUPS: mb.commit(SET_GROUPS), + UPDATE_CREATE_PARAMETERS_FROM_URL: mb.commit(UPDATE_CREATE_PARAMETERS_FROM_URL), + RESET_STATE: mb.commit(RESET_STATE), + + + cleanAssignments: mb.dispatch(cleanAssignments), + changeAssignment: mb.dispatch(changeAssignment), + createNextAssignment: mb.dispatch(createNextAssignment), + skipAssignment: mb.dispatch(skipAssignment), + deleteCurrentAssignment: mb.dispatch(deleteCurrentAssignment), + getAvailableSubmissionCounts: mb.dispatch(getAvailableSubmissionCounts), + getGroups: mb.dispatch(getGroups) +} diff --git a/frontend/src/store/modules/authentication.ts b/frontend/src/store/modules/authentication.ts index cdef4c0cad582b5a465760b189506be74697eea1..a87b556e5185d2541be2d7a9f11b317de1e86413 100644 --- a/frontend/src/store/modules/authentication.ts +++ b/frontend/src/store/modules/authentication.ts @@ -27,7 +27,8 @@ function initialState (): AuthState { user: { pk: '', username: '', - isAdmin: false + isAdmin: false, + exerciseGroups: [] } } } diff --git a/frontend/src/store/modules/feedback_list/feedback-search-options.ts b/frontend/src/store/modules/feedback_list/feedback-search-options.ts index 671301950421070aa6bbc6c42738e608c340efcd..f5e1faee38250dc22d7bdac8777ab8cc0ea44dee 100644 --- a/frontend/src/store/modules/feedback_list/feedback-search-options.ts +++ b/frontend/src/store/modules/feedback_list/feedback-search-options.ts @@ -1,7 +1,7 @@ import { Module } from 'vuex' import { RootState } from '@/store/store' import { getStoreBuilder } from 'vuex-typex' -import { Subscription } from '@/models' +import { FeedbackStageEnum } from '@/models' import { FeedbackLabels } from '../feedback-labels' export const namespace = 'feedbackSearchOptions' @@ -33,8 +33,8 @@ function initialState (): FeedbackSearchOptionsState { const mb = getStoreBuilder<RootState>().module('FeedbackSearchOptions', initialState()) const mapStageDisplayToApiString: {[index: string]: string} = { - 'Initial feedback': Subscription.FeedbackStageEnum.Creation, - 'Validation': Subscription.FeedbackStageEnum.Validation + 'Initial feedback': FeedbackStageEnum.Creation, + 'Validation': FeedbackStageEnum.Validation } const stateGetter = mb.state() diff --git a/frontend/src/store/modules/feedback_list/feedback-table.ts b/frontend/src/store/modules/feedback_list/feedback-table.ts index ef5f9da77bb157c5cafaa5ad72605e066f0ead3f..13ef429913d063b60602f76b4e2240397eeec92c 100644 --- a/frontend/src/store/modules/feedback_list/feedback-table.ts +++ b/frontend/src/store/modules/feedback_list/feedback-table.ts @@ -1,6 +1,6 @@ import { fetchAllFeedback, fetchAllAssignments } from '@/api' import { objectifyArray } from '@/util/helpers' -import { Assignment, Feedback, Subscription, SubmissionType } from '@/models' +import { Assignment, Feedback, FeedbackStageEnum, SubmissionType } from '@/models' import { Module } from 'vuex' import { RootState } from '@/store/store' import { getters } from '@/store/getters' @@ -9,7 +9,7 @@ import { Authentication } from '@/store/modules/authentication' export interface FeedbackHistoryItem extends Feedback { history?: { - [key in Subscription.FeedbackStageEnum]?: { + [key in FeedbackStageEnum]?: { owner: string isDone: boolean } diff --git a/frontend/src/store/modules/submission-notes.ts b/frontend/src/store/modules/submission-notes.ts index b7cffba17195f2a3c4d82897f8e5ab5983edb03d..0870424bdb3c0c4f4ea42eb8122de27d92995837 100644 --- a/frontend/src/store/modules/submission-notes.ts +++ b/frontend/src/store/modules/submission-notes.ts @@ -6,7 +6,7 @@ import { RootState } from '@/store/store' import { getStoreBuilder, BareActionContext } from 'vuex-typex' import { syntaxPostProcess } from '@/util/helpers' import { AxiosResponse } from 'axios' -import { Subscriptions } from './subscriptions' +import { Assignments } from './assignments' export interface SubmissionNotesState { submission: SubmissionNoType @@ -203,7 +203,7 @@ Promise<AxiosResponse<void>[]> { throw new Error('You need to add or change a comment or a feedback label when setting a non full score.') } - const assignment = Subscriptions.state.currentAssignment + const assignment = Assignments.state.currentAssignment if (assignment) { await api.submitFeedbackForAssignment({ feedback , assignment}) } else if (state.hasOrigFeedback) { diff --git a/frontend/src/store/modules/subscriptions.ts b/frontend/src/store/modules/subscriptions.ts deleted file mode 100644 index 88a2cf712dd2dfc7a1124e5faf68569f0371b0c6..0000000000000000000000000000000000000000 --- a/frontend/src/store/modules/subscriptions.ts +++ /dev/null @@ -1,296 +0,0 @@ -import Vue from 'vue' -import * as api from '@/api' -import { cartesian, flatten, once } from '@/util/helpers' -import { Assignment, Subscription } from '@/models' -import { ActionContext, Module } from 'vuex' -import { RootState } from '@/store/store' -import { Authentication } from '@/store/modules/authentication' -import { getStoreBuilder, BareActionContext } from 'vuex-typex' - -export interface SubscriptionsState { - subscriptions: {[pk: string]: Subscription} - currentAssignment?: Assignment - loading: boolean -} - -function initialState (): SubscriptionsState { - return { - subscriptions: {}, - currentAssignment: undefined, - loading: false - } -} - -const mb = getStoreBuilder<RootState>().module('Subscriptions', initialState()) - -const stateGetter = mb.state() - -const availableTypesGetter = mb.read(function availableTypes (state, getters) { - let types = [Subscription.QueryTypeEnum.Random, Subscription.QueryTypeEnum.SubmissionType] - if (Authentication.isReviewer) { - types.push(Subscription.QueryTypeEnum.Exam) - } - return types -}) - -const availableStagesGetter = mb.read(function availableStages (state, getters) { - let stages = [Subscription.FeedbackStageEnum.Creation, Subscription.FeedbackStageEnum.Validation] - if (Authentication.isReviewer) { - stages.push(Subscription.FeedbackStageEnum.ConflictResolution) - } - return stages -}) - -const availableStagesReadableGetter = mb.read(function availableStagesReadable (state, getters) { - let stages = ['initial', 'validate'] - if (Authentication.isReviewer) { - stages.push('conflict') - } - return stages -}) - -const availableSubmissionTypeQueryKeysGetter = mb.read(function availableSubmissionTypeQueryKeys (state, getters, rootState) { - return Object.values(rootState.submissionTypes).map((subType: any) => subType.pk) -}) - -const availableExamTypeQueryKeysGetter = mb.read(function availableExamTypeQueryKeys (state, getters, rootState) { - return Object.values(rootState.examTypes).map((examType: any) => examType.pk) -}) - -const activeSubscriptionGetter = mb.read(function activeSubscription (state) { - if (state.currentAssignment && state.currentAssignment.subscription) { - return state.subscriptions[state.currentAssignment.subscription] - } - - return undefined -}) - -const resolveSubscriptionKeyToNameGetter = mb.read(function resolveSubscriptionKeyToName (state, getters, rootState) { - return (subscription: {queryType: Subscription.QueryTypeEnum, queryKey: string}) => { - switch (subscription.queryType) { - case Subscription.QueryTypeEnum.Random: - return 'Active' - case Subscription.QueryTypeEnum.Exam: - return subscription.queryKey - ? rootState.examTypes[subscription.queryKey].moduleReference : 'Exam' - case Subscription.QueryTypeEnum.SubmissionType: - return subscription.queryKey - ? rootState.submissionTypes[subscription.queryKey].name : 'Submission Type' - case Subscription.QueryTypeEnum.Student: - return subscription.queryKey - ? rootState.students[subscription.queryKey].name : 'Student' - - default: - return undefined - } - } -}) - -type SubscriptionsByStage = {[p in Subscription.FeedbackStageEnum]?: {[k in Subscription.QueryTypeEnum]: Subscription[]}} -// TODO Refactor this monstrosity -const getSubscriptionsGroupedByTypeGetter = mb.read(function getSubscriptionsGroupedByType (state, getters) { - const subscriptionsByType = () => { - return { - [Subscription.QueryTypeEnum.Random]: [], - [Subscription.QueryTypeEnum.Student]: [], - [Subscription.QueryTypeEnum.Exam]: [], - [Subscription.QueryTypeEnum.SubmissionType]: [] - } - } - let subscriptionsByStage: SubscriptionsByStage = Subscriptions.availableStages.reduce((acc: SubscriptionsByStage, - curr: Subscription.FeedbackStageEnum) => { - acc[curr] = subscriptionsByType() - return acc - }, {}) - Object.values(state.subscriptions).forEach((subscription: Subscription) => { - if (subscriptionsByStage && subscription.feedbackStage && subscription.queryType) { - subscriptionsByStage[subscription.feedbackStage]![subscription.queryType].push(subscription) - } - }) - // sort the resulting arrays in subscriptions lexicographically by their query_keys - const sortSubscriptions = (subscriptionsByType: {[k: string]: Subscription[]}) => Object.values(subscriptionsByType) - .forEach((arr: object[]) => { - if (arr.length > 1 && arr[0].hasOwnProperty('queryKey')) { - arr.sort((subA, subB) => { - const subALower = getters.resolveSubscriptionKeyToName(subA).toLowerCase() - const subBLower = getters.resolveSubscriptionKeyToName(subB).toLowerCase() - if (subALower < subBLower) { - return -1 - } else if (subALower > subBLower) { - return 1 - } else { - return 0 - } - }) - } - }) - Object.values(subscriptionsByStage).forEach((subscriptionsByType: any) => { - sortSubscriptions(subscriptionsByType) - }) - return subscriptionsByStage -}) - -function SET_SUBSCRIPTIONS (state: SubscriptionsState, subscriptions: Array<Subscription>): void { - state.subscriptions = subscriptions.reduce((acc: {[pk: string]: Subscription}, curr) => { - acc[curr.pk] = curr - return acc - }, {}) -} - -function SET_SUBSCRIPTION (state: SubscriptionsState, subscription: Subscription): void { - Vue.set(state.subscriptions, subscription.pk, subscription) -} - -function SET_CURRENT_ASSIGNMENT (state: SubscriptionsState, assignment?: Assignment): void { - state.currentAssignment = assignment -} - -function RESET_STATE (state: SubscriptionsState): void { - Object.assign(state, initialState()) - subscribeToAll.reset() -} - -async function subscribeTo ( - context: BareActionContext<SubscriptionsState, RootState>, - { type, key, stage }: - {type: Subscription.QueryTypeEnum, key?: string, stage: Subscription.FeedbackStageEnum}): Promise<Subscription> { - // don't subscribe to type, key, stage combinations if they're already present - let subscription = Subscriptions.getSubscriptionsGroupedByType[stage]![type].find((elem: Subscription) => { - if (type === Subscription.QueryTypeEnum.Random) { - return true - } - return elem.queryKey === key - }) - subscription = subscription || await api.subscribeTo(type, key, stage) - Subscriptions.SET_SUBSCRIPTION(subscription) - return subscription -} - -async function getSubscriptions () { - const subscriptions = await api.fetchSubscriptions() - Subscriptions.SET_SUBSCRIPTIONS(subscriptions) - return subscriptions -} - - -async function changeToSubscription({state}: BareActionContext<SubscriptionsState, RootState>, subscriptionPk: string) { - const currAssignment = state.currentAssignment - if (currAssignment && currAssignment.subscription === subscriptionPk) { - return - } - - if (currAssignment) { - await api.deleteAssignment({assignment: currAssignment}) - } - - const newAssignment = await api.createAssignment({subscriptionPk}) - Subscriptions.SET_CURRENT_ASSIGNMENT(newAssignment) -} - -async function createNextAssignment() { - const activeSubscription = Subscriptions.activeSubscription - if (!activeSubscription) { - throw new Error('There must be an active Subscription before calling createNextAssignment') - } - const newAssignment = await api.createAssignment({subscription: activeSubscription}) - Subscriptions.SET_CURRENT_ASSIGNMENT(newAssignment) -} - -async function cleanAssignmentsFromSubscriptions -({ state }: BareActionContext<SubscriptionsState, RootState>, excludeActive = true) { - Object.values(state.subscriptions).forEach(subscription => { - if (!excludeActive || - !Subscriptions.activeSubscription || - subscription.pk !== Subscriptions.activeSubscription.pk) { - if (subscription.assignments) { - subscription.assignments.forEach(assignment => { - api.deleteAssignment({ assignment }) - }) - } - } - }) -} - -async function skipAssignment ({ state }: BareActionContext<SubscriptionsState, RootState>) { - if (!state.currentAssignment || !state.currentAssignment.subscription) { - throw new Error('skipAssignment can only be called with active assignment') - } - - const newAssignment = await api.createAssignment({subscriptionPk: state.currentAssignment.subscription}) - await api.deleteAssignment({assignment: state.currentAssignment }) - - Subscriptions.SET_CURRENT_ASSIGNMENT(newAssignment) -} - -async function deleteCurrentAssignment ({ state }: BareActionContext<SubscriptionsState, RootState>) { - if (!state.currentAssignment) { - throw new Error('No active assignment to delete') - } - await api.deleteAssignment({assignment: state.currentAssignment}) - Subscriptions.SET_CURRENT_ASSIGNMENT(undefined) -} - -async function subscribeToType -(context: BareActionContext<SubscriptionsState, RootState>, type: Subscription.QueryTypeEnum) { - switch (type) { - case Subscription.QueryTypeEnum.Random: - Subscriptions.availableStages.map((stage: Subscription.FeedbackStageEnum) => { - Subscriptions.subscribeTo({ type, stage }) - }) - break - case Subscription.QueryTypeEnum.Exam: - if (Authentication.isReviewer) { - const stageKeyCartesian = cartesian( - Subscriptions.availableStages, Subscriptions.availableExamTypeQueryKeys) - // @ts-ignore - stageKeyCartesian.map(([stage, key]: [Subscription.FeedbackStageEnum, string]) => { - Subscriptions.subscribeTo({ stage, type, key }) - }) - } - break - case Subscription.QueryTypeEnum.SubmissionType: - const stageKeyCartesian = cartesian( - Subscriptions.availableStages, Subscriptions.availableSubmissionTypeQueryKeys) - // @ts-ignore - stageKeyCartesian.map(([stage, key]: [Subscription.FeedbackStageEnum, string]) => { - Subscriptions.subscribeTo({ stage, type, key }) - }) - break - - default: - return - } -} - -const subscribeToAll = once(async () => { - return Promise.all(flatten(Subscriptions.availableTypes.map((type) => { - return Subscriptions.subscribeToType(type) - }))) -}) - -export const Subscriptions = { - get state () { return stateGetter() }, - get availableTypes () { return availableTypesGetter() }, - get availableStages () { return availableStagesGetter() }, - get availableStagesReadable () { return availableStagesReadableGetter() }, - get availableSubmissionTypeQueryKeys () { return availableSubmissionTypeQueryKeysGetter() }, - get availableExamTypeQueryKeys () { return availableExamTypeQueryKeysGetter() }, - get activeSubscription () { return activeSubscriptionGetter() }, - get resolveSubscriptionKeyToName () { return resolveSubscriptionKeyToNameGetter() }, - get getSubscriptionsGroupedByType () { return getSubscriptionsGroupedByTypeGetter() }, - - SET_SUBSCRIPTIONS: mb.commit(SET_SUBSCRIPTIONS), - SET_SUBSCRIPTION: mb.commit(SET_SUBSCRIPTION), - SET_CURRENT_ASSIGNMENT: mb.commit(SET_CURRENT_ASSIGNMENT), - RESET_STATE: mb.commit(RESET_STATE), - - subscribeTo: mb.dispatch(subscribeTo), - getSubscriptions: mb.dispatch(getSubscriptions), - cleanAssignmentsFromSubscriptions: mb.dispatch(cleanAssignmentsFromSubscriptions), - changeToSubscription: mb.dispatch(changeToSubscription), - createNextAssignment: mb.dispatch(createNextAssignment), - skipAssignment: mb.dispatch(skipAssignment), - deleteCurrentAssignment: mb.dispatch(deleteCurrentAssignment), - subscribeToType: mb.dispatch(subscribeToType), - subscribeToAll: mb.dispatch(subscribeToAll, 'subscribeToAll') -} diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index 7b01426881f6baf63667a0a772b7cb8bc4f26369..439a54b8ab571c597f4c0dcb192fcc9ab90c6125 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -11,7 +11,7 @@ import './modules/feedback_list/feedback-search-options' // @ts-ignore import './modules/feedback_list/feedback-table' // @ts-ignore -import './modules/subscriptions' +import './modules/assignments' // @ts-ignore import './modules/submission-notes' // @ts-ignore @@ -26,7 +26,7 @@ import './getters' import { UIState } from './modules/ui' import { AuthState } from './modules/authentication' import { FeedbackSearchOptionsState } from './modules/feedback_list/feedback-search-options' -import { SubscriptionsState } from './modules/subscriptions' +import { AssignmentsState } from './modules/assignments' import { FeedbackTableState } from './modules/feedback_list/feedback-table' import { SubmissionNotesState } from './modules/submission-notes' import { StudentPageState } from './modules/student-page' @@ -63,7 +63,7 @@ export interface RootState extends RootInitialState{ Authentication: AuthState, FeedbackSearchOptions: FeedbackSearchOptionsState, FeedbackTable: FeedbackTableState, - Subscriptions: SubscriptionsState, + Assignments: AssignmentsState, SubmissionNotes: SubmissionNotesState, StudentPage: StudentPageState, TutorOverview: TutorOverviewState, diff --git a/functional_tests/test_feedback_creation.py b/functional_tests/test_feedback_creation.py index e843de89ddbc89b5b30c6da0d00fff1d2a7dcef2..75a64e2ea52f4e2c06b82abf25984b0179f0d2a0 100644 --- a/functional_tests/test_feedback_creation.py +++ b/functional_tests/test_feedback_creation.py @@ -177,7 +177,7 @@ class UntestedParent: WebDriverWait(self.browser, 10).until(wait_until_code_changes(self, code)) correct() - sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended' + sub_url = 'correction/ended' WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url)) reset_browser_after_test(self.browser, self.live_server_url) # logs out user @@ -198,7 +198,7 @@ class UntestedParent: self.browser.find_element_by_class_name('final-checkbox').click() self.browser.find_element_by_id('submit-feedback').click() - sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended' + sub_url = 'correction/ended' WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url)) reset_browser_after_test(self.browser, self.live_server_url) @@ -209,7 +209,7 @@ class UntestedParent: fact.UserAccountFactory(username=user_rev, password=password, role=role) login(self.browser, self.live_server_url, user_rev, password) - go_to_subscription(self, 'conflict') + go_to_subscription(self, 'review') code = reconstruct_submission_code(self) self.assertEqual(code, code_non_final) @@ -252,7 +252,7 @@ class UntestedParent: self.browser.find_element_by_class_name('final-checkbox').click() self.browser.find_element_by_id('submit-feedback').click() - sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended' + sub_url = 'correction/ended' WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url)) reset_browser_after_test(self.browser, self.live_server_url) # logs out user diff --git a/functional_tests/test_feedback_label_system.py b/functional_tests/test_feedback_label_system.py index 3d737a4839bd280ebc22a7a618ee73cc7e7f8e71..950af8a01037fb45dd4f1eb30647e36f9bf6df3f 100644 --- a/functional_tests/test_feedback_label_system.py +++ b/functional_tests/test_feedback_label_system.py @@ -213,7 +213,7 @@ class FeedbackLabelSystemTest(LiveServerTestCase): self.assertEqual(len(added), 1) self.browser.find_element_by_id('submit-feedback').click() - sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended' + sub_url = 'correction/ended' WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url)) label = FeedbackLabel.objects.get(name='test') @@ -257,7 +257,7 @@ class FeedbackLabelSystemTest(LiveServerTestCase): self.assertGreater(len(current), 1) self.browser.find_element_by_id('submit-feedback').click() - sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended' + sub_url = 'correction/ended' WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url)) new_label = FeedbackLabel.objects.get(name='add') old_label = FeedbackLabel.objects.get(name='test') @@ -325,7 +325,7 @@ class FeedbackLabelSystemTest(LiveServerTestCase): self.assertEqual(len(current), 1) self.browser.find_element_by_id('submit-feedback').click() - sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended' + sub_url = 'correction/ended' WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url)) label = FeedbackLabel.objects.get(name='test') @@ -372,7 +372,7 @@ class FeedbackLabelSystemTest(LiveServerTestCase): self.assertGreater(len(added), 1) self.browser.find_element_by_id('submit-feedback').click() - sub_url = 'subscription/' + str(self.sub_type.pk) + '/ended' + sub_url = 'correction/ended' WebDriverWait(self.browser, 10).until(ec.url_contains(sub_url)) label = FeedbackLabel.objects.get(name='add') diff --git a/functional_tests/test_front_pages.py b/functional_tests/test_front_pages.py index a213fa006e73f121cc2f2fdd5a917a2f60d1916a..710459edf2d7d29f40c0dcbd48809cc45c66bfa6 100644 --- a/functional_tests/test_front_pages.py +++ b/functional_tests/test_front_pages.py @@ -49,18 +49,14 @@ class UntestedParent: self._login() WebDriverWait(self.browser, 10).until(subscriptions_loaded_cond(self.browser)) tasks = self.browser.find_element_by_name('subscription-list') - title = tasks.find_element_by_class_name('v-toolbar__title') - self.assertEqual('Tasks', title.text) - subscription_links = extract_hrefs_hashes( + submission_type_links = extract_hrefs_hashes( tasks.find_elements_by_tag_name('a') ) - subscriptions = models.SubmissionSubscription.objects.filter( - owner__username=self.username, - deactivated=False, - feedback_stage=models.SubmissionSubscription.FEEDBACK_CREATION - ).exclude(query_type=models.SubmissionSubscription.RANDOM) - for sub in subscriptions: - self.assertIn(f'/subscription/{sub.pk}', subscription_links) + sub_types = models.SubmissionType.objects.all() + default_group = models.Group.objects.first() + for sub_type in sub_types: + self.assertIn(f'/correction/{sub_type.pk}/feedback-creation/{default_group.pk}', + submission_type_links) class FrontPageTestsTutor(UntestedParent.FrontPageTestsTutorReviewer): @@ -83,8 +79,6 @@ class FrontPageTestsTutor(UntestedParent.FrontPageTestsTutorReviewer): links = extract_hrefs_hashes(drawer.find_elements_by_tag_name('a')) print(links) self.assertTrue(all(link in links for link in ['/home', '/feedback'])) - task_title = drawer.find_element_by_class_name('v-toolbar__title') - self.assertEqual('Tasks', task_title.text) footer = drawer.find_element_by_class_name('sidebar-footer') feedback_link = footer.find_element_by_css_selector('a.feedback-link') self.assertEqual('Give us Feedback!', feedback_link.text) @@ -113,8 +107,6 @@ class FrontPageTestsReviewer(UntestedParent.FrontPageTestsTutorReviewer): links = extract_hrefs_hashes(drawer.find_elements_by_tag_name('a')) self.assertTrue(all(link in links for link in ['/home', '/feedback', '/participant-overview', '/tutor-overview'])) - task_title = drawer.find_element_by_class_name('v-toolbar__title') - self.assertEqual('Tasks', task_title.text) footer = drawer.find_element_by_class_name('sidebar-footer') feedback_link = footer.find_element_by_css_selector('a.feedback-link') self.assertEqual('Give us Feedback!', feedback_link.text) diff --git a/functional_tests/util.py b/functional_tests/util.py index 42911a3e87d49d007d4c652c551c3cae5e99b694..c27adb42c4d34c5525a8155773a311cb077c2b35 100644 --- a/functional_tests/util.py +++ b/functional_tests/util.py @@ -102,14 +102,20 @@ def go_to_subscription(test_class_instance, stage='initial', sub_type=None): sub_type = sub_type if sub_type is not None else test_class_instance.sub_type sub_type_xpath = f'//*[contains(text(), "{sub_type.name}") ' \ - f'and not(contains(@class, "inactive"))]' + f'and not(contains(@class, "inactive-subscription")) ' \ + f'and contains(@class, "subscription") ' \ + f'and not(ancestor::div[contains(@style,"display: none;")])]' + print(tasks.find_elements_by_xpath(sub_type_xpath)) WebDriverWait(test_class_instance.browser, 10).until( ec.element_to_be_clickable((By.XPATH, sub_type_xpath)), message="SubmissionType not clickable" ) sub_type_el = tasks.find_element_by_xpath(sub_type_xpath) sub_type_el.click() - WebDriverWait(test_class_instance.browser, 10).until(ec.url_contains('subscription')) + WebDriverWait(test_class_instance.browser, 10).until( + ec.url_contains('correction'), + message='URL not change to correction URL' + ) def correct_some_submission(test_class_instance): diff --git a/grady/settings/default.py b/grady/settings/default.py index 36cca69c88ad14a070432e9dc835cde0d64b1fe8..482aa5c594c665557d9a7af3c590cd9c77b6dcdd 100644 --- a/grady/settings/default.py +++ b/grady/settings/default.py @@ -211,5 +211,6 @@ LOGGING = { CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' CONSTANCE_CONFIG = { 'STOP_ON_PASS': (False, "Stop correction when for pass " - "only students when they reach pass score") + "only students when they reach pass score"), + 'SINGLE_CORRECTION': (False, "Set submitted feedback immediately to final and skip validation") } diff --git a/util/factories.py b/util/factories.py index eb1a0411c806c5c2e4f5e52b28990e4d493b6ccc..5f2739bfabac2ac702e231c171b21c09137b49e7 100644 --- a/util/factories.py +++ b/util/factories.py @@ -3,7 +3,7 @@ from xkcdpass import xkcd_password as xp from core import models from core.models import (ExamType, Feedback, StudentInfo, Submission, - SubmissionType, UserAccount) + SubmissionType, UserAccount, Group) STUDENTS = 'students' TUTORS = 'tutors' @@ -53,7 +53,7 @@ class GradyUserFactory: }[role] def _make_base_user(self, username, role, password=None, - store_pw=False, fullname='', **kwargs): + store_pw=False, fullname='', exercise_groups=None, **kwargs): """ This is a specific wrapper for the django update_or_create method of objects. * If now username is passed, a generic one will be generated @@ -76,6 +76,14 @@ class GradyUserFactory: role=role, defaults=kwargs) + exercise_groups = [] if exercise_groups is None else exercise_groups + + groups_in_db = [] + for group in exercise_groups: + groups_in_db.append(Group.objects.get_or_create(name=group)[0].pk) + + user.exercise_groups.set(groups_in_db) + if created or password is not None: password = self.make_password() if password is None else password user.set_password(password) diff --git a/util/factory_boys.py b/util/factory_boys.py index 44dcd02e17f56ff6a6e462588f363897b5c5a029..b8406ee821da3d8a8f067e88bb3b60cab8f4cf36 100644 --- a/util/factory_boys.py +++ b/util/factory_boys.py @@ -29,6 +29,12 @@ class SubmissionTypeFactory(DjangoModelFactory): programming_language = models.SubmissionType.C +class GroupFactory(DjangoModelFactory): + class Meta: + model = models.Group + name = factory.Sequence(lambda n: f"Group [{n}]") + + class UserAccountFactory(DjangoModelFactory): class Meta: model = models.UserAccount @@ -39,6 +45,11 @@ class UserAccountFactory(DjangoModelFactory): username = factory.Sequence(lambda n: f"{fake.user_name()}-{n}") password = factory.PostGenerationMethodCall('set_password', 'redrum-is-murder-reversed') + @factory.post_generation + def exercise_groups(self, create, extracted, **kwargs): + default_group, _ = models.Group.objects.get_or_create(name="Default Group") + self.exercise_groups.add(default_group) + class StudentInfoFactory(DjangoModelFactory): class Meta: @@ -82,16 +93,8 @@ class FeedbackCommentFactory(DjangoModelFactory): of_feedback = factory.SubFactory(FeedbackFactory) -class SubmissionSubscriptionFactory(DjangoModelFactory): - class Meta: - model = models.SubmissionSubscription - - owner = factory.SubFactory(UserAccountFactory) - - class TutorSubmissionAssignmentFactory(DjangoModelFactory): class Meta: model = models.TutorSubmissionAssignment submission = factory.SubFactory(SubmissionFactory) - subscription = factory.SubFactory(SubmissionSubscriptionFactory)