diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py index 7f984f22accc5d6c6607120f37cb7276f1566076..1f103389b8104f24c64220abe9ef2f6f359d9892 100644 --- a/core/migrations/0001_initial.py +++ b/core/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.2 on 2018-02-08 15:11 +# Generated by Django 2.0.2 on 2018-02-10 17:00 import uuid @@ -24,7 +24,6 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserAccount', fields=[ - ('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')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), @@ -35,6 +34,7 @@ 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')), + ('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)), @@ -53,7 +53,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='ExamType', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('exam_type_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('module_reference', models.CharField(max_length=50, unique=True)), ('total_score', models.PositiveIntegerField()), ('pass_score', models.PositiveIntegerField()), @@ -127,8 +127,9 @@ class Migration(migrations.Migration): 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.CharField(blank=True, max_length=75)), + ('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)), @@ -137,7 +138,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='SubmissionType', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submission_type_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=50, unique=True)), ('full_score', models.PositiveIntegerField(default=0)), ('description', models.TextField()), @@ -151,7 +152,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Test', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('test_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(max_length=30)), ('label', models.CharField(max_length=50)), ('annotation', models.TextField()), diff --git a/core/migrations/0002_submissionsubscription_deactivated.py b/core/migrations/0002_auto_20180210_1727.py similarity index 58% rename from core/migrations/0002_submissionsubscription_deactivated.py rename to core/migrations/0002_auto_20180210_1727.py index d10cbe69303a4867e6d3cca71b2d999e2dde362f..6efb9d340dba96f298a40d0beb6411e626f9ebda 100644 --- a/core/migrations/0002_submissionsubscription_deactivated.py +++ b/core/migrations/0002_auto_20180210_1727.py @@ -1,4 +1,4 @@ -# Generated by Django 2.0.2 on 2018-02-08 15:50 +# Generated by Django 2.0.2 on 2018-02-10 17:27 from django.db import migrations, models @@ -10,9 +10,9 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( + migrations.AlterField( model_name='submissionsubscription', - name='deactivated', - field=models.BooleanField(default=False), + name='query_key', + field=models.UUIDField(null=True), ), ] diff --git a/core/models.py b/core/models.py index 77f8fcc0c10bbe8f5461067345404b545b70f2be..d10cc0d2398951372499b30a6c91e2ed083b30fa 100644 --- a/core/models.py +++ b/core/models.py @@ -70,6 +70,9 @@ class ExamType(models.Model): def __str__(self) -> str: return self.module_reference + exam_type_id = models.UUIDField(primary_key=True, + default=uuid.uuid4, + editable=False) module_reference = models.CharField(max_length=50, unique=True) total_score = models.PositiveIntegerField() pass_score = models.PositiveIntegerField() @@ -95,6 +98,9 @@ class SubmissionType(models.Model): solution : TextField A sample solution or a correction guideline """ + submission_type_id = models.UUIDField(primary_key=True, + default=uuid.uuid4, + editable=False) name = models.CharField(max_length=50, unique=True) full_score = models.PositiveIntegerField(default=0) description = models.TextField() @@ -146,6 +152,10 @@ class UserAccount(AbstractUser): Username and password are required. Other fields are optional. """ + user_id = models.UUIDField(primary_key=True, + default=uuid.uuid4, + editable=False) + fullname = models.CharField('full name', max_length=70, blank=True) is_admin = models.BooleanField(default=False) @@ -295,6 +305,10 @@ class Test(models.Model): submission : ForeignKey The submission the tests where unapproved on """ + test_id = models.UUIDField(primary_key=True, + default=uuid.uuid4, + editable=False) + name = models.CharField(max_length=30) label = models.CharField(max_length=50) annotation = models.TextField() @@ -437,9 +451,9 @@ class SubmissionSubscription(models.Model): type_query_mapper = { RANDOM: '__any', - STUDENT_QUERY: 'student__student_id', - EXAM_TYPE_QUERY: 'student__exam__module_reference', - SUBMISSION_TYPE_QUERY: 'type__name', + STUDENT_QUERY: 'student__pk', + EXAM_TYPE_QUERY: 'student__exam__pk', + SUBMISSION_TYPE_QUERY: 'type__pk', } QUERY_CHOICE = ( @@ -472,7 +486,7 @@ class SubmissionSubscription(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='subscriptions') - query_key = models.CharField(max_length=75, blank=True) + query_key = models.UUIDField(null=True) query_type = models.CharField(max_length=75, choices=QUERY_CHOICE, default=RANDOM) @@ -495,8 +509,7 @@ class SubmissionSubscription(models.Model): def _get_submissions_that_do_not_have_final_feedback(self): return self._get_submission_base_query().exclude( - Q(feedback__isnull=False), - Q(feedback__is_final=True) + Q(feedback__isnull=False) & Q(feedback__is_final=True) ).exclude( Q(assignments__subscription__owner=self.owner) ).annotate( @@ -525,6 +538,15 @@ class SubmissionSubscription(models.Model): return stage_candiates + def get_remaining_not_final(self): + return self._get_submissions_that_do_not_have_final_feedback().count() + + def get_available_in_stage(self): + try: + return self._get_available_submissions_in_subscription_stage().count() # noqa + except (SubscriptionTemporarilyEnded, SubscriptionEnded) as err: + return 0 + @transaction.atomic def get_or_create_work_assignment(self): task = self._get_available_submissions_in_subscription_stage().first() diff --git a/core/permissions.py b/core/permissions.py index 21299ccce6e2463e2b531e5a56cf4e9dceebba2d..e52f68a21432ab2e0c6369dac0487ea8376a60fa 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -20,13 +20,14 @@ class IsUserRoleGenericPermission(permissions.BasePermission): ) user = request.user - is_authorized = user.is_authenticated and user.role in self.roles + is_authorized = user.is_superuser or (user.is_authenticated and + user.role in self.roles) if not is_authorized: log.warn('User "%s" has no permission to view %s', user.username, view.__class__.__name__) - return is_authorized or user.is_superuser + return is_authorized class IsStudent(IsUserRoleGenericPermission): diff --git a/core/serializers/feedback.py b/core/serializers/feedback.py index b57b21a69c12a64f6ac44ef4a0e3e38d646023a6..28e3eec7653f8127c7d36d04d482a0ab3b7a544f 100644 --- a/core/serializers/feedback.py +++ b/core/serializers/feedback.py @@ -116,7 +116,7 @@ class FeedbackSerializer(DynamicFieldsModelSerializer): def validate_of_submission(self, submission): feedback = self.instance - if feedback is not None and feedback.submission is not submission: + if feedback is not None and feedback.of_submission.pk != submission.pk: raise serializers.ValidationError( 'It is not allowed to update this field.') diff --git a/core/serializers/generic.py b/core/serializers/generic.py index 20edc3c61fef8a482b9482943bafee6904d99da4..ce377fdd0cc1f2476ad14f34ff63f7622cb3101d 100644 --- a/core/serializers/generic.py +++ b/core/serializers/generic.py @@ -15,6 +15,6 @@ class DynamicFieldsModelSerializer(DynamicFieldsMixin, if fields is not None: # Drop any fields that are not specified in the `fields` argument. allowed = set(fields) - existing = set(self.fields.keys()) + existing = set(self.fields) for field_name in existing - allowed: self.fields.pop(field_name) diff --git a/core/serializers/subscription.py b/core/serializers/subscription.py index ef9a63c808ebb7d13d25a76a64d394893c7f9e66..8bb83418e9ca62355d66685381741afe6d15be96 100644 --- a/core/serializers/subscription.py +++ b/core/serializers/subscription.py @@ -5,13 +5,22 @@ from core.models import (Submission, SubmissionSubscription, from core.serializers import DynamicFieldsModelSerializer, FeedbackSerializer +class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer): + text = serializers.ReadOnlyField() + type_pk = serializers.ReadOnlyField(source='type.pk') + full_score = serializers.ReadOnlyField(source='type.full_score') + + class Meta: + model = Submission + fields = ('pk', 'type_pk', 'text', 'full_score') + + class AssignmentSerializer(DynamicFieldsModelSerializer): - submission_pk = serializers.ReadOnlyField(source='submission.pk') class Meta: model = TutorSubmissionAssignment - fields = ('pk', 'submission_pk', 'is_done', 'subscription',) - read_only_fields = ('is_done',) + fields = ('pk', 'submission', 'is_done', 'subscription',) + read_only_fields = ('is_done', 'submission') extra_kwargs = { 'subscription': {'write_only': True}, } @@ -21,29 +30,27 @@ class AssignmentSerializer(DynamicFieldsModelSerializer): return subscription.get_or_create_work_assignment() -class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer): - text = serializers.ReadOnlyField() - type_pk = serializers.ReadOnlyField(source='type.pk') - full_score = serializers.ReadOnlyField(source='type.full_score') - - class Meta: - model = Submission - fields = ('pk', 'type_pk', 'text', 'full_score') - - -class AssignmentDetailSerializer(DynamicFieldsModelSerializer): - submission = SubmissionAssignmentSerializer() +class AssignmentDetailSerializer(AssignmentSerializer): feedback = FeedbackSerializer(source='submission.feedback') + submission = SubmissionAssignmentSerializer() class Meta: model = TutorSubmissionAssignment - fields = ('pk', 'feedback', 'submission', 'is_done',) + fields = ('pk', 'submission', 'feedback', 'is_done',) class SubscriptionSerializer(DynamicFieldsModelSerializer): owner = serializers.ReadOnlyField(source='owner.username') - query_key = serializers.CharField(required=False) + query_key = serializers.UUIDField(required=False) assignments = AssignmentSerializer(read_only=True, many=True) + remaining = serializers.SerializerMethodField() + available = serializers.SerializerMethodField() + + 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 @@ -54,6 +61,15 @@ class SubscriptionSerializer(DynamicFieldsModelSerializer): 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: @@ -72,5 +88,7 @@ class SubscriptionSerializer(DynamicFieldsModelSerializer): 'query_key', 'feedback_stage', 'deactivated', - 'assignments') + 'assignments', + 'remaining', + 'available') read_only_fields = ('deactivated',) diff --git a/core/tests/test_feedback.py b/core/tests/test_feedback.py index 8397faf857442bedefecefef8d29b71355c06adf..8b377de2c8bc3913d291b1f6df46d292b90f1c5b 100644 --- a/core/tests/test_feedback.py +++ b/core/tests/test_feedback.py @@ -111,8 +111,7 @@ class FeedbackCreateTestCase(APITestCase): self.client.force_authenticate(user=self.tutor) self.subscription = models.SubmissionSubscription.objects.create( owner=self.tutor, - query_type='random', - query_key='' + query_type='random' ) self.assignment = self.subscription.get_or_create_work_assignment() @@ -319,7 +318,6 @@ class FeedbackPatchTestCase(APITestCase): self.subscription = models.SubmissionSubscription.objects.create( owner=self.tutor01, query_type='random', - query_key='' ) self.assignment = self.subscription.get_or_create_work_assignment() data = { diff --git a/core/tests/test_student_page.py b/core/tests/test_student_page.py index 52d09daf1e1e2a6e931670c2a63951f42959b9e8..c8a1365b5ecbad07fbba760276c7774e9a6a2d30 100644 --- a/core/tests/test_student_page.py +++ b/core/tests/test_student_page.py @@ -105,7 +105,7 @@ class StudentPageTests(APITestCase): def test_a_student_submissions_contains_type_id(self): self.assertEqual( self.submission_list_first_entry['type']['pk'], - self.student_info.submissions.first().type.id) + str(self.student_info.submissions.first().type.pk)) def test_submission_data_contains_full_score(self): self.assertEqual( @@ -183,7 +183,7 @@ class StudentSelfSubmissionsTests(APITestCase): def test_a_student_submissions_contains_type_id(self): self.assertEqual( self.submission_list_first_entry['type']['pk'], - self.student_info.submissions.first().type.pk) + str(self.student_info.submissions.first().type.pk)) def test_submission_data_contains_full_score(self): self.assertEqual( diff --git a/core/tests/test_submissiontypeview.py b/core/tests/test_submissiontypeview.py index cbb68f07bf4812ba9d9004dae176922ce5a9b58e..3973d7de02c9b51a3603016941fba0ed85ca6bbe 100644 --- a/core/tests/test_submissiontypeview.py +++ b/core/tests/test_submissiontypeview.py @@ -62,7 +62,7 @@ class SubmissionTypeViewTestRetrieve(APITestCase): self.assertEqual(self.response.status_code, status.HTTP_200_OK) def test_get_id(self): - self.assertEqual(self.pk, self.response.data['pk']) + self.assertEqual(str(self.pk), self.response.data['pk']) def test_get_sumbission_type_name(self): self.assertEqual('Hard question', self.response.data['name']) diff --git a/core/tests/test_subscription_assignment_service.py b/core/tests/test_subscription_assignment_service.py index 247217068f1dc97c57d04cf7f9afeaebb65585d8..1f4b2651bd505c301fa791a0b27dba2c3380db07 100644 --- a/core/tests/test_subscription_assignment_service.py +++ b/core/tests/test_subscription_assignment_service.py @@ -171,6 +171,24 @@ class TestApiEndpoints(APITestCase): ]} ) + def test_remaining_submissions(self): + client = APIClient() + client.force_authenticate(user=self.data['tutors'][0]) + + response = client.post('/api/subscription/', {'query_type': 'random'}) + + self.assertEqual(3, response.data['remaining']) + + def test_available_submissions_in_stage(self): + client = APIClient() + client.force_authenticate(user=self.data['tutors'][0]) + + response = client.post('/api/subscription/', + {'query_type': 'random', + 'feedback_stage': 'feedback-validation'}) + + self.assertEqual(0, response.data['available']) + def test_can_create_a_subscription(self): client = APIClient() client.force_authenticate(user=self.data['tutors'][0]) diff --git a/core/views/feedback.py b/core/views/feedback.py index f0af99b1fa828822e3c77f8ff3ba87e49b9221b1..2b1f4b94a035886ba30a54dfbc66327f671786d7 100644 --- a/core/views/feedback.py +++ b/core/views/feedback.py @@ -1,6 +1,7 @@ import logging from rest_framework import mixins, status, viewsets +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from core import models, permissions, serializers @@ -25,18 +26,14 @@ class FeedbackApiView( return feedback_is_final and user_is_tutor def _get_implicit_assignment_for_user(self, submission): - return models.TutorSubmissionAssignment.objects.get( - subscription__owner=self.request.user, - submission=submission - ) - - def _request_user_does_not_own_assignment(self, serializer): try: - submission = serializer.validated_data['of_submission'] - assignment = self._get_implicit_assignment_for_user(submission) - return assignment.subscription.owner != self.request.user - except models.TutorSubmissionAssignment.DoesNotExist as err: - return True + return models.TutorSubmissionAssignment.objects.get( + subscription__owner=self.request.user, + submission=submission + ) + except models.TutorSubmissionAssignment.DoesNotExist: + raise PermissionDenied( + detail='This user has no permission to create this feedback') def _tutor_attempts_to_set_first_feedback_final(self, serializer): is_final_set = serializer.validated_data.get('is_final', False) @@ -64,16 +61,14 @@ class FeedbackApiView( serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + self._get_implicit_assignment_for_user( + serializer.validated_data['of_submission']) + if self._tutor_attempts_to_set_first_feedback_final(serializer): return Response( {'For tutors it is not allowed to create feedback final.'}, status=status.HTTP_403_FORBIDDEN) - if self._request_user_does_not_own_assignment(serializer): - return Response( - {'This user has no permission to create this feedback'}, - status=status.HTTP_403_FORBIDDEN) - self.perform_create(serializer) return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/core/views/subscription.py b/core/views/subscription.py index 66908d69c963636f17f4adec9342888e274db88f..0797e7e4805cc3198e962682635e42e031c9a1e0 100644 --- a/core/views/subscription.py +++ b/core/views/subscription.py @@ -30,7 +30,7 @@ class SubscriptionApiViewSet( return models.SubmissionSubscription.objects.get( owner=self.request.user, query_type=data.get('query_type', ''), - query_key=data.get('query_key', ''), + query_key=data.get('query_key'), feedback_stage=data.get( 'feedback_stage', models.SubmissionSubscription.FEEDBACK_CREATION)