diff --git a/core/models.py b/core/models.py index b2fba4b1f5a8f3381372cecfbe6d623a730cce3d..1f942494dc85ea659f5396058a7104589aff2e07 100644 --- a/core/models.py +++ b/core/models.py @@ -13,7 +13,7 @@ from collections import OrderedDict from random import randrange from typing import Dict -from django.conf import settings +import constance from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser, UserManager from django.db import models, transaction @@ -22,6 +22,7 @@ from django.db.models import (BooleanField, Case, Count, F, IntegerField, Q, from django.db.models.functions import Coalesce log = logging.getLogger(__name__) +config = constance.config def random_matrikel_no() -> str: @@ -616,7 +617,8 @@ class SubmissionSubscription(models.Model): Returns: QuerySet -- a list of all submissions ready for consumption """ - return self._get_submission_base_query().select_for_update().exclude( + 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) @@ -639,21 +641,23 @@ class SubmissionSubscription(models.Model): f'The task which user {self.owner} subscribed to is done') done_assignments_count = self.assignment_count_on_stage[self.feedback_stage] # noqa - stage_candiates = candidates.filter( + stage_candidates = candidates.filter( done_assignments=done_assignments_count, ) - if stage_candiates.count() == 0: + if stage_candidates.count() == 0: raise SubscriptionTemporarilyEnded( 'Currently unavailable. Please check for more soon. ' - 'Submissions remaining: %s' % stage_candiates.count()) + 'Submissions remaining: %s' % stage_candidates.count()) - if (settings.STOP_ON_PASS and + if (config.STOP_ON_PASS and self.feedback_stage == self.FEEDBACK_CREATION): - stage_candiates = stage_candiates.exclude( - submission__student__passes_exam=True) + stage_candidates = stage_candidates.exclude( + Q(submission__student__passes_exam=True) & + Q(submission__student__exam__pass_only=True) + ) - return stage_candiates + return stage_candidates @transaction.atomic def get_remaining_not_final(self) -> int: diff --git a/core/tests/test_custom_subscription_filter.py b/core/tests/test_custom_subscription_filter.py index 4f11162640f74d9e225bfe942f0268d42f184d6a..64525f58e70c9a03f636e02b80e1a13dcbc5a811 100644 --- a/core/tests/test_custom_subscription_filter.py +++ b/core/tests/test_custom_subscription_filter.py @@ -1,23 +1,34 @@ -from django.conf import settings +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 StopAfterFiftyPercent(APITestCase): + +class StopOnPass(APITestCase): @classmethod def setUpTestData(cls): - settings.STOP_ON_PASS = True + 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, - }], + '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', @@ -40,6 +51,7 @@ class StopAfterFiftyPercent(APITestCase): ], 'students': [ {'username': 'student01', 'exam': 'Test Exam 01'}, + {'username': 'student02', 'exam': 'Test Exam 02'}, ], 'tutors': [ {'username': 'tutor01'}, @@ -49,6 +61,7 @@ class StopAfterFiftyPercent(APITestCase): {'username': 'reviewer'} ], 'submissions': [ + # Student 1 { 'text': 'function blabl\n' ' on multi lines\n' @@ -71,6 +84,31 @@ class StopAfterFiftyPercent(APITestCase): ' 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' } ]} ) @@ -81,30 +119,71 @@ class StopAfterFiftyPercent(APITestCase): feedback_stage=SubmissionSubscription.FEEDBACK_CREATION) def test_all_feedback_is_available(self): - self.assertEqual(3, self.subscription.get_available_in_stage()) + 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(2, self.subscription.get_available_in_stage()) + 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_student_passed_exam(self): + 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(0, self.subscription.get_available_in_stage()) + 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) - def test_validation_still_allowed(self): - a1 = self.subscription.get_or_create_work_assignment() - a2 = self.subscription.get_or_create_work_assignment() + # 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) + Feedback.objects.create( + of_submission=a2.submission, score=15) + + 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( @@ -112,10 +191,10 @@ class StopAfterFiftyPercent(APITestCase): Feedback.objects.create( of_submission=a2.submission, score=15) - subscription = SubmissionSubscription.objects.create( + subscription_other_tutor = SubmissionSubscription.objects.create( owner=self.tutor02, feedback_stage=SubmissionSubscription.FEEDBACK_VALIDATION) - self.assertEqual(0, self.subscription.get_available_in_stage()) - self.assertEqual(2, subscription.get_available_in_stage()) - self.assertEqual(3, subscription.get_remaining_not_final()) + 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/grady/settings/__init__.py b/grady/settings/__init__.py index bffd6fae1d45f127d87fa1097c4880f447701201..cdbdbdffa916989e7a25eb84f63aeb9666a6f118 100644 --- a/grady/settings/__init__.py +++ b/grady/settings/__init__.py @@ -1,6 +1,5 @@ +from .default import * # noqa import os -from .default import * -from .instance import * # noqa dev = os.environ.get('DJANGO_DEV', False) diff --git a/grady/settings/default.py b/grady/settings/default.py index 5d168606f6d449bebe8e3615ab7cd3e1a6fcf3c7..c5ca4ee36aafda97dab7eab30bc3185f555893be 100644 --- a/grady/settings/default.py +++ b/grady/settings/default.py @@ -44,6 +44,8 @@ INSTALLED_APPS = [ 'corsheaders', 'drf_yasg', 'core', + 'constance', + 'constance.backends.database', ] MIDDLEWARE = [ @@ -203,3 +205,10 @@ 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") +} diff --git a/grady/settings/instance.py b/grady/settings/instance.py deleted file mode 100644 index 0f41f4b5fc5e5ec63fbd547e6c273ae1930dcb70..0000000000000000000000000000000000000000 --- a/grady/settings/instance.py +++ /dev/null @@ -1,5 +0,0 @@ - -# Application specific settings. Mount this file in production to -# set instance specific stuff. In the future this information will -# be stored in the database for dynamic configuration -STOP_ON_PASS = False diff --git a/grady/settings/test.py b/grady/settings/test.py index 20e0800134edce6a5b16d3079e645333282efdda..d8eca64b83f3171d73d5e9adf613d14aaa62b7b3 100644 --- a/grady/settings/test.py +++ b/grady/settings/test.py @@ -1,5 +1,4 @@ from .default import * -from .instance import * from .live import * REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']['anon'] = '1000/minute' diff --git a/requirements.txt b/requirements.txt index 9278d9aab8c8d1d6526b55b679617d82c72e3ece..db9655c04f0b0ffbd81473986d43a559e83233f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ tqdm~=4.28.0 whitenoise~=4.1.0 xlrd~=1.2.0 xkcdpass==1.17.0 +django-constance[database]~=2.3.1 diff --git a/util/factories.py b/util/factories.py index da49196e22bb4f0fdb84a7efc6629626d15fc016..6fedf552984211459306f71ba6fcf8293c59429f 100644 --- a/util/factories.py +++ b/util/factories.py @@ -1,6 +1,5 @@ import configparser -import secrets -import string +from xkcdpass import xkcd_password as xp from core import models from core.models import (ExamType, Feedback, StudentInfo, Submission, @@ -12,11 +11,12 @@ REVIEWERS = 'reviewers' PASSWORDS = '.importer_passwords' +words = xp.generate_wordlist(wordfile=xp.locate_wordfile(), min_length=5, max_length=8) -def get_random_password(length=32): + +def get_random_password(numwords=4): """ Returns a cryptographically random string of specified length """ - return ''.join(secrets.choice(string.ascii_lowercase) - for _ in range(length)) + return xp.generate_xkcdpassword(words, numwords=numwords, delimiter='-') def store_password(username, groupname, password): diff --git a/util/importer.py b/util/importer.py index e7475e8843e4a56930fb9b06e89b6e8f4592de16..6a99b6db38a02abbf34e5f725de953130b09f4ee 100644 --- a/util/importer.py +++ b/util/importer.py @@ -122,12 +122,17 @@ def add_tests(submission_obj, tests): add_feedback_if_test_recommends_it(test_obj) -def add_submission(student_obj, code, tests, type): +# submission_type is the name outputted by rust_hektor, type the one from hektor +def add_submission(student_obj, code, tests, submission_type=None, type=None): + if submission_type is None and type is None: + raise Exception("Submission need to contain submission_type or type") + elif type is not None: + submission_type = type - submission_type = SubmissionType.objects.get(name=type) + submission_type_obj = SubmissionType.objects.get(name=submission_type) submission_obj, _ = Submission.objects.update_or_create( - type=submission_type, + type=submission_type_obj, student=student_obj, defaults={'text': code} ) @@ -390,7 +395,6 @@ def do_load_submissions(): for student in exam_data['students']: student_obj = user_factory.make_student(**exam_obj, **student).student - for submission_obj in student['submissions']: add_submission(student_obj, **submission_obj)