Repository: apluslms/a-plus

Issue:
Deadline extensions misbehave if they cross daylight savings time start/end 
It’s not uncommon to grant students long deadline extensions — one week or more. This happens for various reasons (e.g., medical issues, groups of high school students with a different schedule, etc.)

A+ supports this by allowing teachers to give an extension measured in days. The intuitive interpretation of this is that if a deadline at X o’clock is extended by Y days, the new deadline will be at X o’clock the same time, but Y days later. This is, however, not necessarily what happens.

It appears that if daylight savings time starts or ends during the deadline extension, the new deadline will be at X-1 or X+1 o’clock. For example, if I give a 7-day extension to our Wed 6pm deadline from late October to early November, the student will be able to submit until Wed ***5pm*** the following week.

The practical consequence is that teachers should be constantly aware of upcoming clock-windings. (And of course they won’t remember.) And the student will see confusing info about their deadline extension and won’t be able to submit their work close to the extended deadline that was intended. 

This also affects the (again not-super-rare) case that multiple deadlines are extended at the same time. For example, there are cases where a student’s entire course schedule needs to be shifted by, say, three weeks. The convenient way to do this is to go to the DL extension form, select all the modules and mark 21 days as the extension. What then happens is that the September, October, and December deadlines will shift appropriately but the November deadlines will be one hour too early.  

Suggested solution: When using the Days field in the form, automatically check whether a daylight savings boundary is crossed and adjust the extension length accordingly.

(Side note: in principle, someone could decide to give a week’s extension by extending the DL by 10080 minutes instead of 7 days. Before the Days field was added, that was indeed the only way to do it. But I think that strategy can now be considered an exotic relic of the past, and the fix could focus on just the Days field that actually matters.)



Discussion:
Related but harder to fix: https://github.com/apluslms/a-plus/issues/1137


Resolving patch:
diff --git a/deviations/models.py b/deviations/models.py
--- a/deviations/models.py
+++ b/deviations/models.py
@@ -3,6 +3,7 @@
 
 from django.db import models
 from django.utils.translation import gettext_lazy as _
+import pytz
 
 from course.models import CourseInstance
 from exercise.exercise_models import BaseExercise
@@ -197,6 +198,30 @@ def update_by_form(self, form_data: Dict[str, Any]) -> None:
             seconds = self.exercise.delta_in_seconds_from_closing_to_date(new_date)
         else:
             seconds = int(seconds)
+
+            # Adjust the deadline deviation length by taking daylight savings time into consideration
+            timezone_string = form_data.get('timezone_string')
+            try:
+                tz = pytz.timezone(timezone_string)
+
+                module_close = self.get_normal_deadline()
+                new_deadline = module_close + timedelta(seconds=seconds)
+                start_date = module_close.replace(tzinfo=pytz.utc).astimezone(tz)
+                end_date = new_deadline.replace(tzinfo=pytz.utc).astimezone(tz)
+
+                # Get the UTC offsets for the start and end dates
+                start_offset = start_date.utcoffset().total_seconds()
+                end_offset = end_date.utcoffset().total_seconds()
+
+                if start_offset > end_offset:
+                    # Clock was moved backward, add one hour to the deadline deviation
+                    seconds += 60 * 60
+                elif start_offset < end_offset:
+                    # Clock was moved forward, remove one hour from the deadline deviation
+                    seconds -= 60 * 60
+            except pytz.exceptions.UnknownTimeZoneError:
+                pass
+
         self.extra_seconds = seconds
         self.without_late_penalty = bool(form_data.get('without_late_penalty'))
 
diff --git a/deviations/views.py b/deviations/views.py
--- a/deviations/views.py
+++ b/deviations/views.py
@@ -49,6 +49,11 @@ def serialize_session_data(self, form_data: Dict[str, Any]) -> Dict[str, Any]:
         })
         return result
 
+    def form_valid(self, form):
+        timezone_string = self.request.POST.get('timezone_string')
+        form.cleaned_data['timezone_string'] = timezone_string
+        return super().form_valid(form)
+
 
 class OverrideDeadlinesView(OverrideDeviationsView):
     template_name = "deviations/override_dl.html"


Test patch: