From 2420432a8dc368481ebcaa6579578919957c84d8 Mon Sep 17 00:00:00 2001 From: Mikael Lenander Date: Tue, 29 Aug 2023 12:19:53 +0300 Subject: [PATCH] Chapter may be set as a model answer to course modules Add a new feature that enables controlling chapter visibility to students based on the completion of a course module. There are multiple alternative rules for revealing the chapter since the feature uses the same "reveal rules" feature that has previously been developed for the visibility of assignment model solutions and submission feedback. Fixes #1098 --- ...0058_coursemodule_model_answer_and_more.py | 26 ++++ course/models.py | 33 ++++- edit_course/course_forms.py | 30 +++- exercise/cache/points.py | 62 ++++++-- exercise/exercise_models.py | 22 +++ exercise/permissions.py | 4 + exercise/reveal_states.py | 108 ++++++++++++-- exercise/templates/exercise/_children.html | 2 +- .../templates/exercise/_user_results.html | 13 +- exercise/tests.py | 132 +++++++++++++++++- exercise/tests_cache.py | 35 ++++- locale/en/LC_MESSAGES/django.po | 28 +++- locale/fi/LC_MESSAGES/django.po | 29 +++- 13 files changed, 481 insertions(+), 43 deletions(-) create mode 100644 course/migrations/0058_coursemodule_model_answer_and_more.py diff --git a/course/migrations/0058_coursemodule_model_answer_and_more.py b/course/migrations/0058_coursemodule_model_answer_and_more.py new file mode 100644 index 000000000..3064b5c76 --- /dev/null +++ b/course/migrations/0058_coursemodule_model_answer_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.4 on 2023-08-29 13:50 + +from django.db import migrations +import django.db.models.deletion +import lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('exercise', '0048_alter_revealrule_trigger'), + ('course', '0057_usertagging_rename_index'), + ] + + operations = [ + migrations.AddField( + model_name='coursemodule', + name='model_answer', + field=lib.fields.DefaultForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='model_answer_modules', to='exercise.coursechapter', verbose_name='LABEL_MODEL_ANSWER'), + ), + migrations.AddField( + model_name='coursemodule', + name='model_solution_reveal_rule', + field=lib.fields.DefaultOneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='exercise.revealrule', verbose_name='LABEL_MODEL_SOLUTION_REVEAL_RULE'), + ), + ] diff --git a/course/models.py b/course/models.py index 9f2359bf3..941a95fcf 100644 --- a/course/models.py +++ b/course/models.py @@ -29,7 +29,7 @@ from apps.models import BaseTab, BasePlugin from authorization.models import JWTAccessible from authorization.object_permissions import register_jwt_accessible_class -from lib.fields import PercentField +from lib.fields import PercentField, DefaultOneToOneField, DefaultForeignKey from lib.helpers import ( Enum, get_random_string, @@ -1282,6 +1282,23 @@ class CourseModule(UrlMixin, models.Model): default=0.5, help_text=_('MODULE_LATE_SUBMISSION_PENALTY_HELPTEXT'), ) + model_answer = DefaultForeignKey( + 'exercise.CourseChapter', + verbose_name=_('LABEL_MODEL_ANSWER'), + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="model_answer_modules", + ) + model_solution_reveal_rule = DefaultOneToOneField( + 'exercise.RevealRule', + verbose_name=_('LABEL_MODEL_SOLUTION_REVEAL_RULE'), + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + objects = CourseModuleManager() @@ -1317,6 +1334,20 @@ def clean(self): if errors: raise ValidationError(errors) + @property + def default_model_solution_reveal_rule(self): + from exercise.models import RevealRule # pylint: disable=import-outside-toplevel + return RevealRule( + trigger=RevealRule.TRIGGER.DEADLINE, + ) + + @property + def active_model_solution_reveal_rule(self): + return ( + self.model_solution_reveal_rule + or self.default_model_solution_reveal_rule + ) + @staticmethod def check_is_open(reading_opening_time, opening_time, closing_time, when=None): when = when or timezone.now() diff --git a/edit_course/course_forms.py b/edit_course/course_forms.py index f862659d3..5b79dc35e 100644 --- a/edit_course/course_forms.py +++ b/edit_course/course_forms.py @@ -68,7 +68,8 @@ class Meta: 'closing_time', 'late_submissions_allowed', 'late_submission_deadline', - 'late_submission_penalty' + 'late_submission_penalty', + 'model_answer', ] widgets = { 'opening_time': DateTimeLocalInput, @@ -77,14 +78,39 @@ class Meta: 'late_submission_deadline': DateTimeLocalInput, } + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + from .exercise_forms import RevealRuleForm # pylint: disable=import-outside-toplevel + self.model_solution_form = RevealRuleForm( + data=kwargs.get('data'), + instance=self.instance.active_model_solution_reveal_rule, + prefix='model_solution', + ) + def get_fieldsets(self): return [ { 'legend':_('HIERARCHY'), 'fields':self.get_fields('status','order','url') }, - { 'legend':_('CONTENT'), 'fields':self.get_fields('name','introduction','points_to_pass') }, + { 'legend':_('CONTENT'), 'fields': + self.get_fields('name','introduction','points_to_pass', 'model_answer') }, + { 'legend':_('REVEAL_MODEL_SOLUTIONS'), 'fields': self.model_solution_form }, { 'legend':_('SCHEDULE'), 'fields':self.get_fields('reading_opening_time','opening_time','closing_time', 'late_submissions_allowed','late_submission_deadline', 'late_submission_penalty') }, ] + def is_valid(self) -> bool: + return ( + super().is_valid() + and self.model_solution_form.is_valid() + ) + + def save(self, *args: Any, **kwargs: Any) -> Any: + if self.model_solution_form.has_changed(): + self.instance.model_solution_reveal_rule = ( + self.model_solution_form.save(*args, **kwargs) + ) + return super().save(*args, **kwargs) + class CourseInstanceForm(forms.ModelForm): diff --git a/exercise/cache/points.py b/exercise/cache/points.py index c9fa6294d..1f2ab828a 100644 --- a/exercise/cache/points.py +++ b/exercise/cache/points.py @@ -16,14 +16,14 @@ from django.db.models.signals import post_save, post_delete, m2m_changed from django.utils import timezone -from course.models import CourseInstance +from course.models import CourseInstance, CourseModule from deviations.models import DeadlineRuleDeviation, MaxSubmissionsRuleDeviation, SubmissionRuleDeviation from lib.cache import CachedAbstract from lib.helpers import format_points from notification.models import Notification from userprofile.models import UserProfile from ..models import BaseExercise, Submission, RevealRule -from ..reveal_states import ExerciseRevealState +from ..reveal_states import ExerciseRevealState, ModuleRevealState from .content import CachedContent from .hierarchy import ContentMixin @@ -104,13 +104,19 @@ def _generate_data( # pylint: disable=arguments-differ MaxSubmissionsRuleDeviation.objects .get_max_deviations(user.userprofile, exercises) ) + module_instances = list( + instance.course_modules.all() + ) else: submissions = [] deadline_deviations = [] submission_deviations = [] + module_instances = [] # Generate the staff and student version of the cache, and merge them. - generate_args = (user.is_authenticated, submissions, deadline_deviations, submission_deviations) + generate_args = ( + user.is_authenticated, submissions, deadline_deviations, submission_deviations, module_instances + ) staff_data = self._generate_data_internal(True, *generate_args) student_data = self._generate_data_internal(False, *generate_args) self._pack_tuples(staff_data, student_data) # Now staff_data is the final, combined data. @@ -136,6 +142,7 @@ def _generate_data_internal( # noqa: MC0001 submissions: Iterable[Submission], deadline_deviations: Iterable[DeadlineRuleDeviation], submission_deviations: Iterable[MaxSubmissionsRuleDeviation], + module_instances: Iterable[CourseModule], ) -> Dict[str, Any]: """ Handles the generation of one version of the cache (staff or student). @@ -143,7 +150,7 @@ def _generate_data_internal( # noqa: MC0001 arguments to this method. """ data = deepcopy(self.content.data) - module_index = data['module_index'] # pylint: disable=unused-variable + module_index = data['module_index'] exercise_index = data['exercise_index'] modules = data['modules'] categories = data['categories'] @@ -171,6 +178,9 @@ def r_augment(children: List[Dict[str, Any]]) -> None: 'feedback_revealed': True, 'feedback_reveal_time': None, }) + entry.update({ + 'is_revealed': True, + }) r_augment(entry.get('children')) for module in modules: module.update({ @@ -232,6 +242,17 @@ def r_augment(children: List[Dict[str, Any]]) -> None: final_submission = None last_submission = None + def update_invalidation_time(invalidate_time: Optional[datetime.datetime]) -> None: + if ( + invalidate_time is not None + and invalidate_time > timezone.now() + and ( + data['invalidate_time'] is None + or invalidate_time < data['invalidate_time'] + ) + ): + data['invalidate_time'] = invalidate_time + def check_reveal_rule() -> None: """ Evaluate the reveal rule of the current exercise and ensure @@ -262,15 +283,7 @@ def check_reveal_rule() -> None: # If the reveal rule depends on time, update the cache's # invalidation time. - if ( - reveal_time is not None - and reveal_time > timezone.now() - and ( - data['invalidate_time'] is None - or reveal_time < data['invalidate_time'] - ) - ): - data['invalidate_time'] = reveal_time + update_invalidation_time(reveal_time) # Augment submission data. for submission in submissions: @@ -379,6 +392,29 @@ def check_reveal_rule() -> None: if exercise is not None and not is_staff: check_reveal_rule() + if not is_staff: + def update_is_revealed_recursive(entry: Dict[str, Any], is_revealed: bool) -> None: + if is_revealed: + return + entry.update({ + 'is_revealed': is_revealed, + }) + for child in entry.get('children', []): + update_is_revealed_recursive(child, is_revealed) + + for module in module_instances: + model_chapter = module.model_answer + if model_chapter is None: + continue + reveal_rule = module.active_model_solution_reveal_rule + entry = self._by_idx(modules, exercise_index[model_chapter.id])[-1] + cached_module = self._by_idx(modules, module_index[module.id])[-1] + state = ModuleRevealState(cached_module) + is_revealed = reveal_rule.is_revealed(state) + reveal_time = reveal_rule.get_reveal_time(state) + update_is_revealed_recursive(entry, is_revealed) + update_invalidation_time(reveal_time) + # Confirm points. def r_check(parent: Dict[str, Any], children: List[Dict[str, Any]]) -> None: for entry in children: diff --git a/exercise/exercise_models.py b/exercise/exercise_models.py index 78a3790e7..928344328 100644 --- a/exercise/exercise_models.py +++ b/exercise/exercise_models.py @@ -280,6 +280,16 @@ def course_instance(self): def is_submittable(self): return False + def can_be_shown_as_module_model_solution(self, user: User) -> bool: + """ + If this exercise's parent chapter is a model solution to modules, check if the chapter + can be revealed to the user according to the module's reveal rule. + """ + if self.parent is not None: + return self.parent.can_be_shown_as_module_model_solution(user) + return True + + def is_empty(self): return not self.service_url and self.as_leaf_class()._is_empty() @@ -441,6 +451,18 @@ class Meta: verbose_name = _('MODEL_NAME_COURSE_CHAPTER') verbose_name_plural = _('MODEL_NAME_COURSE_CHAPTER_PLURAL') + def can_be_shown_as_module_model_solution(self, user: User) -> bool: + """ + If this chapter is a model solution to modules, check if the chapter + can be revealed to the user according to the module's reveal rule. + """ + from .cache.content import CachedContent # pylint: disable=import-outside-toplevel + from .cache.points import CachedPoints # pylint: disable=import-outside-toplevel + content = CachedContent(self.course_instance) + points = CachedPoints(self.course_instance, user, content) + entry, _, _, _ = points.find(self) + return entry['is_revealed'] + def _is_empty(self): return not self.generate_table_of_contents diff --git a/exercise/permissions.py b/exercise/permissions.py index c076d46cb..aff7a1e1a 100644 --- a/exercise/permissions.py +++ b/exercise/permissions.py @@ -67,6 +67,10 @@ def is_object_visible(self, request, view, exercise): # pylint: disable=argument self.error_msg(_('EXERCISE_VISIBLITY_ERROR_ONLY_EXTERNAL_USERS')) return False + if not exercise.can_be_shown_as_module_model_solution(user): + self.error_msg(_('MODULE_MODEL_ANSWER_VISIBILITY_PERMISSION_DENIED_MSG')) + return False + return True diff --git a/exercise/reveal_states.py b/exercise/reveal_states.py index 6386d1d4c..d93393e90 100644 --- a/exercise/reveal_states.py +++ b/exercise/reveal_states.py @@ -4,9 +4,31 @@ from django.contrib.auth.models import User from deviations.models import DeadlineRuleDeviation +from course.models import CourseModule from .cache.content import CachedContent from .exercise_models import BaseExercise +def _get_exercise_common_deadlines(exercise: Dict[str, Any]) -> List[datetime.datetime]: + deadlines = [exercise['closing_time']] + if exercise['late_allowed'] and exercise['late_percent'] > 0: + deadlines.append(exercise['late_time']) + return deadlines + + +def _get_exercise_deadline(exercise: Dict[str, Any]) -> datetime.datetime: + deadlines = _get_exercise_common_deadlines(exercise) + personal_deadline = exercise['personal_deadline'] + if personal_deadline is not None: + deadlines.append(personal_deadline) + return max(deadlines) + + +def _get_max_submissions(exercise: Dict[str, Any]) -> int: + personal_max_submissions = exercise['personal_max_submissions'] + if personal_max_submissions is not None: + return personal_max_submissions + return exercise['max_submissions'] + class BaseRevealState: """ @@ -76,17 +98,10 @@ def get_submissions(self) -> Optional[int]: return self.cache['submission_count'] def get_max_submissions(self) -> Optional[int]: - personal_max_submissions = self.cache['personal_max_submissions'] - if personal_max_submissions is not None: - return personal_max_submissions - return self.cache['max_submissions'] + return _get_max_submissions(self.cache) def get_deadline(self) -> Optional[datetime.datetime]: - deadlines = self._get_common_deadlines() - personal_deadline = self.cache['personal_deadline'] - if personal_deadline is not None: - deadlines.append(personal_deadline) - return max(deadlines) + return _get_exercise_deadline(self.cache) def get_latest_deadline(self) -> Optional[datetime.datetime]: deadlines = self._get_common_deadlines() @@ -106,7 +121,74 @@ def get_latest_deadline(self) -> Optional[datetime.datetime]: return max(deadlines) def _get_common_deadlines(self) -> List[datetime.datetime]: - deadlines = [self.cache['closing_time']] - if self.cache['late_allowed'] and self.cache['late_percent'] > 0: - deadlines.append(self.cache['late_time']) - return deadlines + return _get_exercise_common_deadlines(self.cache) + + +class ModuleRevealState(BaseRevealState): + @overload + def __init__(self, module: CourseModule, student: User): + ... + @overload + def __init__(self, module: Dict[str, Any]): + ... + def __init__(self, module: Union[CourseModule, Dict[str, Any]], student: Optional[User] = None): + if isinstance(module, CourseModule): + from .cache.points import CachedPoints # pylint: disable=import-outside-toplevel + cached_content = CachedContent(module.course_instance) + cached_points = CachedPoints(module.course_instance, student, cached_content, True) + self.module_id = module.id + cached_module, _, _, _ = cached_points.find(module) + self.module = cached_module + else: + self.module_id = module['id'] + self.module = module + self.exercises = self._get_exercises() + self.max_deviation_fetched: bool = False + self.max_deviation: Optional[DeadlineRuleDeviation] = None + + def get_deadline(self) -> Optional[datetime.datetime]: + return max(_get_exercise_deadline(exercise) for exercise in self.exercises) + + def get_latest_deadline(self) -> Optional[datetime.datetime]: + deadlines = [] + exercise_dict = {} + for exercise in self.exercises: + deadlines.extend(_get_exercise_common_deadlines(exercise)) + exercise_dict[exercise['id']] = exercise + if not self.max_deviation_fetched: + self.max_deviation = ( + DeadlineRuleDeviation.objects + .filter(exercise__course_module_id=self.module_id) + .order_by('-extra_minutes').first() + ) + self.max_deviation_fetched = True + if self.max_deviation is not None: + deadlines.append( + self.max_deviation.get_new_deadline(exercise_dict[self.max_deviation.exercise_id]['closing_time']) + ) + return max(deadlines) + + def get_points(self) -> Optional[int]: + points = sum(exercise['points'] for exercise in self.exercises) + return points + + def get_max_points(self) -> Optional[int]: + return self.module['max_points'] + + def get_submissions(self) -> Optional[int]: + return sum(min(exercise['submission_count'], _get_max_submissions(exercise)) for exercise in self.exercises) + + def get_max_submissions(self) -> Optional[int]: + return sum(_get_max_submissions(exercise) for exercise in self.exercises) + + def _get_exercises(self) -> List[Dict[str, Any]]: + exercises = [] + + def recursion(children: Dict[str, Any]) -> None: + for entry in children: + if entry['type'] == 'exercise' and entry['submittable']: + exercises.append(entry) + recursion(entry.get('children', [])) + + recursion(self.module['children']) + return exercises diff --git a/exercise/templates/exercise/_children.html b/exercise/templates/exercise/_children.html index 89bc9aa05..514cea86f 100644 --- a/exercise/templates/exercise/_children.html +++ b/exercise/templates/exercise/_children.html @@ -10,7 +10,7 @@ {% endif %} {% elif entry|is_listed %}
  • - {% if accessible and not entry.submittable or exercise_accessible %} + {% if accessible and not entry.submittable and entry.is_revealed or exercise_accessible and entry.is_revealed %} {% if entry.is_empty %} {{ entry.name|parse_localization }} {% else %} diff --git a/exercise/templates/exercise/_user_results.html b/exercise/templates/exercise/_user_results.html index 69b018140..6ec7f3a17 100644 --- a/exercise/templates/exercise/_user_results.html +++ b/exercise/templates/exercise/_user_results.html @@ -130,7 +130,7 @@

    {% if entry.submittable and entry|is_visible or entry.submittable and is_teacher %} - {% if exercise_accessible or is_course_staff %} + {% if exercise_accessible and entry.is_revealed or is_course_staff %} {{ entry.name|parse_localization }} {% if entry|deadline_extended_exercise_open:now %} {% elif entry.type == 'exercise' and entry|is_visible or entry.type == 'exercise' and is_teacher %} - {% if accessible %} + {% if accessible and entry.is_revealed %} {{ entry.name|parse_localization }} {% else %} {{ entry.name|parse_localization }} + {% if not entry.is_revealed and accessible %} + + + {% translate "MODULE_MODEL_ANSWER_NOT_VISIBLE" %} + + {% endif %} {% endif %} {% if is_course_staff %} diff --git a/exercise/tests.py b/exercise/tests.py index 0b50f29da..10bdf90b2 100644 --- a/exercise/tests.py +++ b/exercise/tests.py @@ -20,9 +20,9 @@ from exercise.exercise_summary import UserExerciseSummary from exercise.models import BaseExercise, StaticExercise, \ ExerciseWithAttachment, Submission, SubmittedFile, LearningObject, \ - RevealRule + RevealRule, CourseChapter from exercise.protocol.exercise_page import ExercisePage -from exercise.reveal_states import ExerciseRevealState +from exercise.reveal_states import ExerciseRevealState, ModuleRevealState from exercise.submission_models import build_upload_dir as build_upload_dir_for_submission_model from lib.helpers import build_aplus_url @@ -975,6 +975,43 @@ def test_can_show_model_solutions(self): self.assertTrue(base_exercise_with_late_closed.can_show_model_solutions_to_student(self.user)) self.assertTrue(base_exercise_with_late_closed.can_show_model_solutions_to_student(self.user2)) + def test_can_be_shown_as_module_model_solution(self): + chapter = CourseChapter.objects.create( + name="test course chapter", + course_module=self.course_module, + category=self.learning_object_category, + url="c1", + ) + deadline_deviation_old_base_exercise = DeadlineRuleDeviation.objects.create( + exercise=self.old_base_exercise, + submitter=self.user.userprofile, + granter=self.teacher.userprofile, + extra_minutes=1440, # One day + ) + reveal_rule = RevealRule.objects.create( + trigger=RevealRule.TRIGGER.DEADLINE, + ) + self.old_course_module.model_answer = chapter + self.old_course_module.model_answer_reveal_rule = reveal_rule + self.old_course_module.save() + self.base_exercise.parent = chapter + self.base_exercise.save() + self.static_exercise.parent = self.base_exercise + self.static_exercise.save() + # Chapter is model answer to a closed module with a deadline extension + self.assertFalse(chapter.can_be_shown_as_module_model_solution(self.user)) + # Unrevealed chapter's child + self.assertFalse(self.base_exercise.can_be_shown_as_module_model_solution(self.user)) + # Unrevealed chapter's grandchild + self.assertFalse(self.static_exercise.can_be_shown_as_module_model_solution(self.user)) + self.assertTrue(chapter.can_be_shown_as_module_model_solution(self.user2)) + + deadline_deviation_old_base_exercise.extra_minutes = 0 + deadline_deviation_old_base_exercise.save() + self.assertTrue(chapter.can_be_shown_as_module_model_solution(self.user)) + self.assertTrue(self.base_exercise.can_be_shown_as_module_model_solution(self.user)) + self.assertTrue(self.static_exercise.can_be_shown_as_module_model_solution(self.user)) + def test_reveal_rule(self): reveal_rule = RevealRule.objects.create( trigger=RevealRule.TRIGGER.MANUAL, @@ -1126,6 +1163,97 @@ def test_reveal_rule_full_points(self): completion_test_base_exercise.delete() + def test_module_reveal_state(self): + course_module_chapter = CourseChapter.objects.create( + name="test course chapter", + course_module=self.course_module, + category=self.learning_object_category, + url="c1", + ) + optional_exercise = BaseExercise.objects.create( + order=4, + name="test exercise 4", + course_module=self.course_module, + category=self.learning_object_category, + url="b4", + max_submissions=0, + max_points=1, + ) + self.base_exercise.parent = course_module_chapter + self.base_exercise.max_points = 5 + # max submissions 1 + self.base_exercise.save() + self.static_exercise.parent = course_module_chapter + self.static_exercise.max_submissions = 2 + # max points 50 + self.static_exercise.save() + self.exercise_with_attachment.max_submissions = 1 + # max points 50 + self.exercise_with_attachment.save() + + DeadlineRuleDeviation.objects.create( + exercise=self.old_base_exercise, + submitter=self.user.userprofile, + granter=self.teacher.userprofile, + extra_minutes=4320, + ) # this should have not effect on the module reveal state + + user_reveal_state = ModuleRevealState(self.course_module, self.user) + self.assertEqual(len(user_reveal_state.exercises), 4) + self.assertEqual(user_reveal_state.get_deadline(), self.two_days_from_now) # User has a deadline deviation + user2_reveal_state = ModuleRevealState(self.course_module, self.user2) + self.assertEqual(user2_reveal_state.get_deadline(), self.tomorrow) # User2 has no deadline deviation + self.assertEqual(user2_reveal_state.get_latest_deadline(), self.two_days_from_now) + self.assertEqual(user_reveal_state.get_latest_deadline(), self.two_days_from_now) + + user_reveal_state_with_late_submissions = ModuleRevealState( + self.course_module_with_late_submissions_allowed, self.user + ) + self.assertEqual(user_reveal_state_with_late_submissions.get_deadline(), self.two_days_from_now) + + Submission.objects.all().delete() + for submission_data in [ + { + 'exercise': self.base_exercise, + 'grade': 5, + 'status': Submission.STATUS.READY, + }, + { + 'exercise': optional_exercise, + 'grade': 1, + 'status': Submission.STATUS.READY, + }, # Should have no effect on submission count, since max_submissions is 0 + { + 'exercise': self.static_exercise, + 'grade': 10, + 'status': Submission.STATUS.READY, + }, + { + 'exercise': self.static_exercise, + 'grade': 40, + 'status': Submission.STATUS.READY, + }, + { + 'exercise': self.static_exercise, + 'grade': 50, + 'status': Submission.STATUS.UNOFFICIAL, + }, # Should have no effect + { + 'exercise': self.old_base_exercise, + 'grade': 10, + 'status': Submission.STATUS.READY, + }, # Should have no effect + ]: + submission = Submission.objects.create(**submission_data) + submission.submitters.add(self.user.userprofile) + + user_reveal_state = ModuleRevealState(self.course_module, self.user) + self.assertEqual(user_reveal_state.get_points(), 46) + self.assertEqual(user_reveal_state.get_max_points(), 106) + self.assertEqual(user_reveal_state.get_submissions(), 3) + self.assertEqual(user_reveal_state.get_max_submissions(), 4) + + def test_annotate_submitter_points(self): points_test_base_exercise_1 = BaseExercise.objects.create( name="points test base exercise 1", diff --git a/exercise/tests_cache.py b/exercise/tests_cache.py index e057fe2d5..ccf4ac0b5 100644 --- a/exercise/tests_cache.py +++ b/exercise/tests_cache.py @@ -3,7 +3,8 @@ from .cache.content import CachedContent from .cache.hierarchy import PreviousIterator from .cache.points import CachedPoints -from .models import BaseExercise, StaticExercise, Submission +from .models import BaseExercise, StaticExercise, Submission, CourseChapter, RevealRule +from deviations.models import DeadlineRuleDeviation class CachedContentTest(CourseTestCase): @@ -240,3 +241,35 @@ def test_unofficial(self): self.assertTrue(entry['graded']) self.assertFalse(entry['unofficial']) self.assertEqual(entry['points'], 50) + + def test_is_revealed(self): + module_chapter = CourseChapter.objects.create( + name="test course chapter", + course_module=self.module, + category=self.category, + url="c1", + ) + DeadlineRuleDeviation.objects.create( + exercise=self.exercise0, + submitter=self.student.userprofile, + granter=self.teacher.userprofile, + extra_minutes=2*24*60, + ) + reveal_rule = RevealRule.objects.create( + trigger=RevealRule.TRIGGER.DEADLINE, + ) + self.exercise.parent = module_chapter + self.exercise.save() + self.module0.model_answer = module_chapter + self.module0.model_solution_reveal_rule = reveal_rule + self.module0.save() + c = CachedContent(self.instance) + p = CachedPoints(self.instance, self.student, c) + entry0, _, _, _ = p.find(self.exercise0) + entry, _, _, _ = p.find(self.exercise) + entry2, _, _, _ = p.find(self.exercise2) + chapter_entry, _, _, _ = p.find(module_chapter) + self.assertTrue(entry0['is_revealed']) + self.assertFalse(entry['is_revealed']) + self.assertTrue(entry2['is_revealed']) + self.assertFalse(chapter_entry['is_revealed']) diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 8908b46b0..b168a001b 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -739,6 +739,14 @@ msgstr "late submission penalty" msgid "MODULE_LATE_SUBMISSION_PENALTY_HELPTEXT" msgstr "Multiplier of points to reduce as a decimal (e.g. 0.1 = 10%)." +#: course/models.py +msgid "LABEL_MODEL_ANSWER" +msgstr "model answer" + +#: course/models.py +msgid "LABEL_MODEL_SOLUTION_REVEAL_RULE" +msgstr "Model solution reveal rule" + #: course/models.py msgid "MODEL_NAME_COURSE_MODULE" msgstr "course module" @@ -1976,6 +1984,10 @@ msgstr "Hierarchy" msgid "CONTENT" msgstr "Content" +#: edit_course/course_forms.py edit_course/exercise_forms.py +msgid "REVEAL_MODEL_SOLUTIONS" +msgstr "Reveal model solutions" + #: edit_course/course_forms.py msgid "SCHEDULE" msgstr "Schedule" @@ -2262,10 +2274,6 @@ msgstr "Grading" msgid "REVEAL_SUBMISSION_FEEDBACK" msgstr "Reveal submission feedback" -#: edit_course/exercise_forms.py -msgid "REVEAL_MODEL_SOLUTIONS" -msgstr "Reveal model solutions" - #: edit_course/managers.py msgid "CATEGORY_lowercase" msgstr "category" @@ -3494,6 +3502,12 @@ msgstr "The assignment is only for internal users." msgid "EXERCISE_VISIBLITY_ERROR_ONLY_EXTERNAL_USERS" msgstr "The assignment is only for external users." +#: exercise/permissions.py +msgid "MODULE_MODEL_ANSWER_VISIBILITY_PERMISSION_DENIED_MSG" +msgstr "" +"Unfortunately you are not permitted to view this content, since it contains " +"model answers to assignments whose deadline has not yet passed." + #: exercise/permissions.py msgid "EXERCISE_ASSISTANT_PERMISSION_DENIED_MSG" msgstr "Unfortunately, assistants are not permitted to view this." @@ -4084,6 +4098,12 @@ msgstr "No submissions yet" msgid "INSPECT" msgstr "Inspect" +#: exercise/templates/exercise/_user_results.html +msgid "MODULE_MODEL_ANSWER_NOT_VISIBLE" +msgstr "" +"This chapter is hidden, since it contains model answers to assignments whose " +"deadline has not yet passed." + #: exercise/templates/exercise/_user_toc.html #: lti_tool/templates/lti_tool/lti_course.html msgid "OPENS" diff --git a/locale/fi/LC_MESSAGES/django.po b/locale/fi/LC_MESSAGES/django.po index bd1b94308..22e8f5ea4 100644 --- a/locale/fi/LC_MESSAGES/django.po +++ b/locale/fi/LC_MESSAGES/django.po @@ -743,6 +743,14 @@ msgstr "myöhästymissakko" msgid "MODULE_LATE_SUBMISSION_PENALTY_HELPTEXT" msgstr "Pisteiden vähennyskerroin desimaalilukuna, esim. 0,1 = 10%" +#: course/models.py +msgid "LABEL_MODEL_ANSWER" +msgstr "esimerkkiratkaisu" + +#: course/models.py +msgid "LABEL_MODEL_SOLUTION_REVEAL_RULE" +msgstr "malliratkaisun paljastamissääntö" + #: course/models.py msgid "MODEL_NAME_COURSE_MODULE" msgstr "kurssimoduuli" @@ -1987,6 +1995,10 @@ msgstr "Hierarkia" msgid "CONTENT" msgstr "Sisältö" +#: edit_course/course_forms.py edit_course/exercise_forms.py +msgid "REVEAL_MODEL_SOLUTIONS" +msgstr "Malliratkaisun paljastaminen" + #: edit_course/course_forms.py msgid "SCHEDULE" msgstr "Aikataulu" @@ -2276,10 +2288,6 @@ msgstr "Arvostelu" msgid "REVEAL_SUBMISSION_FEEDBACK" msgstr "Tehtävien palautteen paljastaminen" -#: edit_course/exercise_forms.py -msgid "REVEAL_MODEL_SOLUTIONS" -msgstr "Malliratkaisujen paljastaminen" - #: edit_course/managers.py msgid "CATEGORY_lowercase" msgstr "kategoria" @@ -3505,6 +3513,13 @@ msgstr "Tämä tehtävä on vain oppilaitoksen koulutusohjelmien opiskelijoille. msgid "EXERCISE_VISIBLITY_ERROR_ONLY_EXTERNAL_USERS" msgstr "Tämä tehtävä on vain oppilaitoksen ulkopuolisille opiskelijoille." +#: exercise/permissions.py +msgid "MODULE_MODEL_ANSWER_VISIBILITY_PERMISSION_DENIED_MSG" +msgstr "" +"Valitettavasti sinulla ei ole oikeutta nähdä tätä sisältöä, sillä se " +"sisältää esimerkkiratkaisuja tehtäviin, joiden määräaika ei ole vielä " +"umpeutunut." + #: exercise/permissions.py msgid "EXERCISE_ASSISTANT_PERMISSION_DENIED_MSG" msgstr "Valitettavasti assistenteilla ei ole oikeutta nähdä tätä." @@ -4095,6 +4110,12 @@ msgstr "Ei vielä palautuksia" msgid "INSPECT" msgstr "Tutki" +#: exercise/templates/exercise/_user_results.html +msgid "MODULE_MODEL_ANSWER_NOT_VISIBLE" +msgstr "" +"Tämä luku on piilotettu, sillä se sisältää esimerkkiratkaisuja tehtäviin, " +"joiden määräaika ei ole vielä umpeutunut." + #: exercise/templates/exercise/_user_toc.html #: lti_tool/templates/lti_tool/lti_course.html msgid "OPENS"