Skip to content

Commit

Permalink
starting work on due date
Browse files Browse the repository at this point in the history
see #315
  • Loading branch information
christianp committed Jun 4, 2024
1 parent 03d35c9 commit 1274848
Show file tree
Hide file tree
Showing 10 changed files with 202 additions and 33 deletions.
23 changes: 20 additions & 3 deletions numbas_lti/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,34 @@ class ResourceSettingsForm(FieldsetFormMixin, ModelForm):
template_name = 'numbas_lti/management/resource_settings_form.html'
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={'class':'form-control', 'placeholder': getattr(settings,'LOCKDOWN_APP',{}).get('password','')}),
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2024-05-30 13:39

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('numbas_lti', '0091_alter_lticonsumerregistrationtoken_options'),
]

operations = [
migrations.AddField(
model_name='accesschange',
name='due_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='Due date'),
),
migrations.AddField(
model_name='resource',
name='due_date',
field=models.DateTimeField(blank=True, null=True, verbose_name='Due date'),
),
]
18 changes: 18 additions & 0 deletions numbas_lti/migrations/0093_resource_allow_student_reopen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2024-05-30 14:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('numbas_lti', '0092_accesschange_due_date_resource_due_date'),
]

operations = [
migrations.AddField(
model_name='resource',
name='allow_student_reopen',
field=models.BooleanField(default=True, verbose_name='Allow students to re-open attempts while the resource is available?'),
),
]
48 changes: 35 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 @@ -33,14 +34,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


requests = requests_session.get_session()
Expand Down Expand Up @@ -390,6 +392,15 @@ def __str__(self):
def get_absolute_url(self):
return reverse('edit_seb_settings',args=(self.pk,))

@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 @@ -402,7 +413,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 @@ -480,6 +493,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 @@ -492,8 +506,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 @@ -516,15 +535,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 @@ -537,20 +558,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 @@ -827,6 +848,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
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
1 change: 1 addition & 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
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 @@ -87,7 +87,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 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 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
22 changes: 22 additions & 0 deletions numbas_lti/templates/numbas_lti/show_attempts.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ <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}}
Expand Down Expand Up @@ -80,6 +92,16 @@ <h1>
{% include "numbas_lti/review_not_allowed.html" with allow_review_from=attempt.resource.allow_review_from %}
{% endif %}
{% endif %}

{% if resource.allow_student_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 class="button {% if attempt.completed %}info{% else %}primary{% endif %}" href="{% url_with_lti 'run_attempt' pk=attempt.pk %}">
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 1274848

Please sign in to comment.