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 %}