Repository: apluslms/a-plus

Issue:
File download button needed in submitted file modal dialogs
(By downloading, I mean that the browser downloads the file to the user's computer. Firefox usually shows a dialog while Chrome usually downloads it immediately. The A+ submitted file URL already supports the parameter `?download=yes`.) 

The student can see file contents in some dialogs, but there is no button for downloading the file to the user's computer. A download button would be very good to have in some places: 

* model solution modal dialog in chapter exercises,
* model solution page in the full-view exercise,
* submission template/skeleton code modal dialog in chapter exercises, 
* submission template file in the full-view exercise page,
* submitted file modal dialog in the submission modal in chapter exercises and
* the submitted file modal dialog in full-view submission pages outside chapters

_Originally posted by @Mankro in https://github.com/apluslms/a-plus/pull/893#discussion_r738336787_

PR #893 already has a quick implementation for the download button in the inspect submission page. Other places need the download button too.


Discussion:
From #485:

> > **Migrated from Trello**
> 
> In A+, the exercise template files (e.g., skeleton code for the student in a programming exercise) functionality currently only shows the content of the files in a modal dialog. The user may not directly download the file. A+ should enable users to download the files too instead of just copy-pasting from the modal dialog.
> 
> The modal could, for example, have a download button that downloads the actual file to the student's computer. This download URL could use some GET query parameter (?download=yes) so that the URL is almost the same as what is used for the current modal.
> 
> **Comments**
> 
> > **Jaakko Kantojärvi:**
> > As extra, add a feature to download all template files as a zip (tar.gz can be created in memory, maybe zips can be created too)
> 
> > **Jaakko Kantojärvi:**
> > The modal could, for example, have a download button that downloads the actual file to the student's computer. This download URL could use some GET query parameter `(?download=yes)` so that the URL is almost the same as what is used for the current modal.



Resolving patch:
diff --git a/exercise/exercise_models.py b/exercise/exercise_models.py
--- a/exercise/exercise_models.py
+++ b/exercise/exercise_models.py
@@ -368,11 +368,11 @@ def get_load_url(
 
     def get_models(self):
         entries = pick_localized(self.model_answers, get_language())
-        return [(url,url.split('/')[-1]) for url in entries.split()]
+        return [(url,url.split('/')[-1].split('?', 1)[0]) for url in entries.split()]
 
     def get_templates(self):
         entries = pick_localized(self.templates, get_language())
-        return [(url,url.split('/')[-1]) for url in entries.split()]
+        return [(url,url.split('/')[-1].split('?', 1)[0]) for url in entries.split()]
 
     def get_form_spec_keys(self, include_static_fields=False):
         """Return the keys of the form fields of this exercise.
diff --git a/exercise/views.py b/exercise/views.py
--- a/exercise/views.py
+++ b/exercise/views.py
@@ -1,4 +1,4 @@
-from typing import Any, List
+from typing import Any, Dict, List, Optional, Sequence, Tuple
 
 from django.conf import settings
 from django.contrib import messages
@@ -18,7 +18,7 @@
 from authorization.permissions import ACCESS
 from course.models import CourseModule
 from course.viewbase import CourseInstanceBaseView, EnrollableViewMixin
-from lib.helpers import query_dict_to_list_of_tuples
+from lib.helpers import query_dict_to_list_of_tuples, safe_file_name
 from lib.remote_page import RemotePageNotFound, request_for_response
 from lib.viewbase import BaseRedirectMixin, BaseView
 from userprofile.models import UserProfile
@@ -357,6 +357,13 @@ def dispatch(self, request, *args, **kwargs):
         return super().dispatch(request, *args, **kwargs)
 
 
+def _find_name(name: str, objects: Sequence[Dict[str, Any]]) -> Tuple[str, Optional[str]]:
+    for obj in objects:
+        if not name or obj['name'] == name:
+            return safe_file_name(obj['name']), obj.get('content', '')
+    return safe_file_name(name), None
+
+
 class ExerciseModelView(ExerciseModelBaseView):
     template_name = "exercise/model.html"
     ajax_template_name = "exercise/_model_files.html"
@@ -382,6 +389,17 @@ def get_common_objects(self):
                 })
         self.note('models')
 
+    def get(self, request, *args, **kwargs):
+        if request.GET.get('download', False):
+            requested_name, data = _find_name(request.GET.get('name', ''), self.models)
+            if data is None:
+                return HttpResponseNotFound(f"The file {requested_name} does not exist.")
+
+            response = HttpResponse(data, content_type='text/plain')
+            response['Content-Disposition'] = f'attachment; filename="{requested_name}"'
+            return response
+
+        return super().get(request, *args, **kwargs)
 
 class ExerciseTemplateView(ExerciseTemplateBaseView):
     template_name = "exercise/template.html"
@@ -408,6 +426,18 @@ def get_common_objects(self):
                 })
         self.note('templates')
 
+    def get(self, request, *args, **kwargs):
+        if request.GET.get('download', False):
+            requested_name, data = _find_name(request.GET.get('name', ''), self.templates)
+            if data is None:
+                return HttpResponseNotFound(f"The file {requested_name} does not exist.")
+
+            response = HttpResponse(data, content_type='text/plain')
+            response['Content-Disposition'] = f'attachment; filename="{requested_name}"'
+            return response
+
+        return super().get(request, *args, **kwargs)
+
 
 class SubmissionView(SubmissionBaseView):
     template_name = "exercise/submission.html"


Test patch: