Skip to content

Commit

Permalink
Chapter may be set as a model answer to course modules
Browse files Browse the repository at this point in the history
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 apluslms#1098
  • Loading branch information
Mikael-Lenander authored and markkuriekkinen committed Aug 29, 2023
1 parent 08663d3 commit 2420432
Show file tree
Hide file tree
Showing 13 changed files with 481 additions and 43 deletions.
26 changes: 26 additions & 0 deletions course/migrations/0058_coursemodule_model_answer_and_more.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
33 changes: 32 additions & 1 deletion course/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down
30 changes: 28 additions & 2 deletions edit_course/course_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):

Expand Down
62 changes: 49 additions & 13 deletions exercise/cache/points.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -136,14 +142,15 @@ 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).
All source data is prefetched by `_generate_data` and provided as
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']
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions exercise/exercise_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions exercise/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading

0 comments on commit 2420432

Please sign in to comment.