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