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"