Skip to content

Commit

Permalink
starting work on due date
Browse files Browse the repository at this point in the history
see #315

The Resource model now has a field `allow_student_reopen`, which
determines whether students can re-open their own attempts.

Students can re-open their own attempts if the resource is still
available, and the resource allows it.
  • Loading branch information
christianp committed Jul 12, 2024
1 parent 109d3bc commit 8a22d7c
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 34 deletions.
23 changes: 20 additions & 3 deletions numbas_lti/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,34 @@ def fieldsets(self):
class ResourceSettingsForm(FieldsetFormMixin, ModelForm):
class Meta:
model = Resource
fields = ['grading_method','include_incomplete_attempts','max_attempts','show_marks_when','report_mark_time','allow_review_from','available_from','available_until','email_receipts','require_lockdown_app', 'lockdown_app_password', 'seb_settings', 'show_lockdown_app_password']
fields = [
'grading_method',
'include_incomplete_attempts',
'max_attempts',
'show_marks_when',
'report_mark_time',
'allow_review_from',
'allow_student_reopen',
'available_from',
'due_date',
'available_until',
'email_receipts',
'require_lockdown_app',
'lockdown_app_password',
'seb_settings',
'show_lockdown_app_password'
]
fieldsets = [
(_('Grading'), ('grading_method', 'include_incomplete_attempts',)),
(_('Attempts'), ('max_attempts',)),
(_('Attempts'), ('max_attempts', 'allow_student_reopen',)),
(_('Feedback'), ('show_marks_when', 'report_mark_time', 'allow_review_from', 'email_receipts',)),
(_('Availability'), ('available_from', 'available_until')),
(_('Availability'), ('available_from', 'due_date', 'available_until')),
(_('Lockdown app'), ('require_lockdown_app', 'lockdown_app_password', 'seb_settings', 'show_lockdown_app_password')),
]
widgets = {
'allow_review_from': DateTimeInput(format=datetime_format),
'available_from': DateTimeInput(format=datetime_format),
'due_date': DateTimeInput(format=datetime_format),
'available_until': DateTimeInput(format=datetime_format),
'lockdown_app_password': forms.TextInput(attrs={'placeholder': getattr(settings,'LOCKDOWN_APP',{}).get('password','')}),
}
Expand Down
57 changes: 44 additions & 13 deletions numbas_lti/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from collections import defaultdict
from dataclasses import dataclass
from django.conf import settings
from django.contrib.auth.models import User
from django.core import signing
Expand Down Expand Up @@ -34,14 +35,15 @@
import re
import shutil
import time
from typing import Optional
import uuid
from zipfile import ZipFile

from . import requests_session
from .groups import group_for_attempt, group_for_resource_stats, group_for_resource
from .report_outcome import report_outcome, report_outcome_for_attempt, ReportOutcomeException
from .diff import make_diff, apply_diff
from .util import parse_scorm_timeinterval
from .util import parse_scorm_timeinterval, iso_time

class LineItemDoesNotExist(Exception):
def __init__(self, resource):
Expand Down Expand Up @@ -447,6 +449,15 @@ def get_absolute_url(self):
('unwanted', _('Not wanted')),
]

@dataclass
class Availability:
"""
A representation of the times when a resource is available.
"""
from_time: Optional[datetime]
until_time: Optional[datetime]
due_date: Optional[datetime]

class Resource(models.Model):
exam = models.ForeignKey(Exam,blank=True,null=True,on_delete=models.SET_NULL,related_name='main_exam_of')
title = models.CharField(max_length=300,default='')
Expand All @@ -459,7 +470,9 @@ class Resource(models.Model):
show_marks_when = models.CharField(max_length=20, default='always', choices=SHOW_SCORES_MODES, verbose_name=_('When to show scores to students'))
available_from = models.DateTimeField(blank=True, null=True, verbose_name=_('Available from'))
available_until = models.DateTimeField(blank=True, null=True, verbose_name=_('Available until'))
due_date = models.DateTimeField(blank=True, null=True, verbose_name=_('Due date'))
allow_review_from = models.DateTimeField(blank=True, null=True, verbose_name=_('Allow students to review attempts from'))
allow_student_reopen = models.BooleanField(default=True, verbose_name=_('Allow students to re-open attempts while the resource is available?'))
report_mark_time = models.CharField(max_length=20,choices=REPORT_TIMES,default='immediately',verbose_name=_('When to report scores back'))
email_receipts = models.BooleanField(default=False,verbose_name=_('Email attempt receipts to students on completion?'))

Expand Down Expand Up @@ -546,6 +559,7 @@ def students(self):
def available_for_user(self,user=None):
afrom = self.available_from
auntil = self.available_until
due_date = self.due_date

deadline_extension = timedelta(0)
if user is not None:
Expand All @@ -558,8 +572,13 @@ def available_for_user(self,user=None):
afrom = change.available_from
if change.available_until is not None:
auntil = change.available_until
if change.due_date is not None:
due_date = change.due_date

return (afrom, auntil + deadline_extension if auntil is not None else None)
if auntil is not None:
auntil += deadline_extension

return Availability(from_time=afrom, until_time=auntil, due_date=self.due_date)

def duration_extension_for_user(self, user):
duration = 0
Expand All @@ -582,15 +601,17 @@ def duration_disabled_for_user(self, user):
return self.access_changes.for_user(user).filter(disable_duration=True).exists()

def availability_json(self,user=None):
available_from, available_until = self.available_for_user(user)
availability = self.available_for_user(user)

if user is not None:
extension_amount, extension_units = self.duration_extension_for_user(user)
else:
extension_amount, extension_units = None, None
data = {
'available_from': available_from.isoformat() if available_from else None,
'available_until': available_until.isoformat() if available_until else None,
'allow_review_from': self.allow_review_from.isoformat() if self.allow_review_from else None,
'available_from': iso_time(availability.from_time),
'available_until': iso_time(availability.until_time),
'due_date': iso_time(availability.due_date),
'allow_review_from': iso_time(self.allow_review_from),
'duration_extension': {
'amount': extension_amount,
'units': extension_units,
Expand All @@ -603,20 +624,20 @@ def is_available(self,user=None):
if user is not None and user.is_anonymous:
return False

available_from, available_until = self.available_for_user(user)
availability = self.available_for_user(user)

if available_from is None and available_until is None:
if availability.from_time is None and availability.until_time is None:
return True

now = timezone.now()

available = False
if available_from is None or available_until is None:
available = (available_from is None or now >= available_from) and (available_until is None or now<=available_until)
elif available_from < available_until:
available = available_from <= now <= available_until
if availability.from_time is None or availability.until_time is None:
available = (availability.from_time is None or now >= availability.from_time) and (availability.until_time is None or now<=availability.until_time)
elif availability.from_time < availability.until_time:
available = availability.from_time <= now <= availability.until_time
else:
available = now <= available_until or now >= available_from
available = now <= availability.until_time or now >= availability.from_time

return available

Expand Down Expand Up @@ -928,6 +949,7 @@ class AccessChange(models.Model):
resource = models.ForeignKey(Resource,on_delete=models.CASCADE,related_name='access_changes')
available_from = models.DateTimeField(blank=True, null=True, verbose_name=_('Available from'))
available_until = models.DateTimeField(blank=True, null=True, verbose_name=_('Available until'))
due_date = models.DateTimeField(blank=True, null=True, verbose_name=_('Due date'))
extend_deadline = models.DurationField(blank=True, null=True, verbose_name=_('Extend deadline by'))
max_attempts = models.PositiveIntegerField(blank=True, null=True, verbose_name=_('Maximum attempts per user'), help_text=_('Zero means unlimited attempts.'))
extend_duration = models.FloatField(blank=True, null=True, verbose_name=_('Extend exam duration by'))
Expand Down Expand Up @@ -1265,6 +1287,15 @@ def completed(self):
return True
return self.completion_status=='completed'

def student_can_reopen(self):
if not self.completed():
return False

if not self.resource.is_available(self.user):
return False

return self.resource.allow_student_reopen

def finalise(self):
if self.end_time is None:
self.end_time = timezone.now()
Expand Down
31 changes: 30 additions & 1 deletion numbas_lti/static/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ function SCORM_API(options) {
this.fallback_url = options.fallback_url;
this.show_attempts_url = options.show_attempts_url;

/** The time that this launch of the attempt started.
*/
this.session_start_time = new Date();

/** Key to save data under in localStorage
*/
this.localstorage_key = 'numbas-lti-attempt-'+this.attempt_pk+'-scorm-data';
Expand Down Expand Up @@ -111,16 +115,23 @@ SCORM_API.prototype = {
*/
last_error: 0,

/** Was this attempt launched after the due date?
*/
launched_after_due_date: false,

/** Update the availability dates for the resource
*/
update_availability_dates: function(data,first) {
var sc = this;
this.allow_review_from = load_date(data.allow_review_from);
var available_from = load_date(data.available_from);
var available_until = load_date(data.available_until);
var due_date = load_date(data.due_date);
var changed = !(dates_equal(available_from, this.available_from) && dates_equal(available_until, this.available_until));
this.available_from = available_from;
this.available_until = available_until;
this.due_date = due_date;
this.launched_after_due_date = data.launched_after_due_date;

var unavailable_time = this.unavailable_time();
if(unavailable_time) {
Expand Down Expand Up @@ -299,6 +310,7 @@ SCORM_API.prototype = {
/** Force the exam to end.
*/
end: function(reason) {
this.SetValue('cmi.completion_status','completed');
if(reason!==undefined) {
this.SetValue('x.reason ended',reason);
}
Expand Down Expand Up @@ -381,6 +393,13 @@ SCORM_API.prototype = {
if(!this.is_available()) {
this.end('not available');
}

var now = get_now();
// Close the attempt when the due date passes, if the attempt wasn't launched after the due date.
if(this.due_date !== undefined && !this.launched_after_due_date && now >= this.due_date) {
this.end('due date passed');
}

}
},

Expand Down Expand Up @@ -439,12 +458,21 @@ SCORM_API.prototype = {
/** The time that the attempt becomes unavailable.
*/
unavailable_time: function() {
if(this.offline || this.available_until === undefined) {
if(this.offline) {
return;
}

if(this.due_date !== undefined && !this.launched_after_due_date) {
return this.due_date;
}

if(this.available_until === undefined) {
return;
}
if(this.available_from===undefined || this.available_from < this.available_until) {
return this.available_until;
} else {
// available_from is after available_until, so this resource is available for all time except the interval between those times.
return;
}
},
Expand All @@ -458,6 +486,7 @@ SCORM_API.prototype = {
return true;
}
var now = get_now();

if(this.available_from===undefined || this.available_until===undefined) {
return (this.available_from===undefined || now >= this.available_from) && (this.available_until===undefined || now <= this.available_until);
}
Expand Down
5 changes: 5 additions & 0 deletions numbas_lti/static/numbas_lti.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ a.button:not(:hover, :focus) {
padding: var(--double-space);
margin: var(--quad-space) 0;
border: thin solid var(--alert-color);
max-width: max-content;
}

.alert > :is(h1,h2,h3,h4,h5,h6) {
Expand Down Expand Up @@ -593,6 +594,10 @@ body.manage-consumer .period-name {
top: 3em;
}

body.show-attempts table.attempts form {
display: inline;
}

.card-list {
display: flex;
flex-wrap: wrap;
Expand Down
7 changes: 6 additions & 1 deletion numbas_lti/templates/numbas_lti/management/attempts.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ <h1>{% translate "Attempts" %}</h1>
<span class="danger">{% translate "Broken" %}</span>
{% else %}
<span class="{% if attempt.completed %}success{% endif %}">{{attempt.get_completion_status_display}}</span>
{% if attempt.completed %}<a title="{% blocktranslate with identifier=attempt.user.get_full_name start_time=attempt.start_time %}Reopen attempt by {{identifier}} started at {{start_time}}{% endblocktranslate %}" class="button link" href="{% url_with_lti 'reopen_attempt' attempt.pk %}"><span class="warning">{% icon 'eye-open' %} {% translate "Reopen" %}</span></a>{% endif %}
{% if attempt.completed %}
<form method="POST" action="{% url_with_lti 'reopen_attempt' attempt.pk %}">
{% csrf_token %}
<button title="{% blocktranslate with identifier=attempt.user.get_full_name start_time=attempt.start_time %}Reopen attempt by {{identifier}} started at {{start_time}}{% endblocktranslate %}" type="submit" class="button link" href=""><span class="warning">{% icon 'eye-open' %} {% translate "Reopen" %}</span></button>
</form>
{% endif %}
{% endif %}
</td>
<td class="attempt-score">
Expand Down
32 changes: 30 additions & 2 deletions numbas_lti/templates/numbas_lti/show_attempts.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

{% block title %}{{resource.title}} - {{block.super}}{% endblock title %}

{% block body_class %}show-attempts {{block.super}}{% endblock %}

{% block content %}
<header>
<h1>
Expand All @@ -28,13 +30,25 @@ <h1>
</div>
{% endif %}

{% if is_available %}
{% if due_date_passed %}
<div class="alert info">
<p>{% blocktranslate %}The due date for this activity has passed, but it is still open. You can continue to attempt this activity, subject to any late work policy.{% endblocktranslate %}</p>
</div>
{% endif %}
{% else %}
<div class="alert info">
<p>{% blocktranslate %}This activity is now closed.{% endblocktranslate %}{% if object_list.exists %} {% blocktranslate %}You can review your existing attempts.{% endblocktranslate %}{% endif %}</p>
</div>
{% endif %}

{% for message in messages %}
<div class="alert info">
{{message}}
</div>
{% endfor %}

<table>
<table class="attempts">
<thead>
<tr>
<th scope="col">{% translate "Start time" %}</th>
Expand Down Expand Up @@ -74,6 +88,16 @@ <h1>
<a title="{% blocktranslate with start_time=attempt.start_time %}Review attempt started at {{start_time}}{% endblocktranslate %}" class="button info" href="{% url_with_lti 'run_attempt' pk=attempt.pk %}">
{% icon 'play' %} {% translate "Review this attempt" %}
</a>

{% if attempt.student_can_reopen %}
<form action="{% url_with_lti 'reopen_attempt' attempt.pk %}" method="POST">
{% csrf_token %}
<button type="submit" class="button warning">
{% icon 'eye-open' %}
{% translate "Re-open this attempt" %}
</button>
</form>
{% endif %}
{% else %}
{% if attempt.resume_allowed %}
<a title="{% blocktranslate with start_time=attempt.start_time %}Continue attempt started at {{start_time}}{% endblocktranslate %}" class="button {% if attempt.completed %}info{% else %}primary{% endif %}" href="{% url_with_lti 'run_attempt' pk=attempt.pk %}">
Expand All @@ -88,7 +112,11 @@ <h1>
</table>

{% if resource.show_marks_when == 'review' and resource.allow_review_from %}
<p class="warning">{% blocktranslate with time_iso=resource.allow_review_from|date:"c" time=resource.allow_review_from %}Full review will be available from <time datetime="{{time_iso}}">{{time}}</time>.{% endblocktranslate %}</p>
{% if review_allowed %}
<p class="info">{% blocktranslate %}Full review is now allowed.{% endblocktranslate %}</p>
{% else %}
<p class="warning">{% blocktranslate with time_iso=resource.allow_review_from|date:"c" time=resource.allow_review_from %}Full review will be available from <time datetime="{{time_iso}}">{{time}}</time>.{% endblocktranslate %}</p>
{% endif %}
{% endif %}

<p>{% blocktranslate %}These are the feedback settings for this activity:{% endblocktranslate %}</p>
Expand Down
7 changes: 6 additions & 1 deletion numbas_lti/util.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import string
import re
from datetime import timedelta
from datetime import timedelta, datetime
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from django.http import QueryDict

Expand Down Expand Up @@ -86,3 +86,8 @@ def add_query_param(url,extras):
)
return url

def iso_time(time: datetime) -> str:
"""
Convert a datetime to an ISO format string if it's not None, otherwise return None.
"""
return time.isoformat() if time else None
Loading

0 comments on commit 8a22d7c

Please sign in to comment.