From 9906c865e417b71f537dafec20fbc8915eeb0b7a Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 25 Nov 2024 22:38:19 +0100 Subject: [PATCH 1/6] Introduce field for setting main language of evaluations --- evap/contributor/forms.py | 4 +++ evap/contributor/tests/test_views.py | 20 +++++++++++ .../0148_evaluation_main_language.py | 24 +++++++++++++ evap/evaluation/models.py | 9 +++++ evap/staff/forms.py | 1 + evap/staff/tests/test_views.py | 35 +++++++++++++++++++ 6 files changed, 93 insertions(+) create mode 100644 evap/evaluation/migrations/0148_evaluation_main_language.py diff --git a/evap/contributor/forms.py b/evap/contributor/forms.py index c1e9678083..f9b6e9c6ae 100644 --- a/evap/contributor/forms.py +++ b/evap/contributor/forms.py @@ -1,6 +1,7 @@ from datetime import datetime from django import forms +from django.conf import settings from django.db.models import Q from django.forms.widgets import CheckboxSelectMultiple from django.utils.translation import gettext_lazy as _ @@ -24,6 +25,7 @@ class Meta: fields = ( "name_de_field", "name_en_field", + "main_language", "vote_start_datetime", "vote_end_date", "participants", @@ -40,6 +42,8 @@ def __init__(self, *args, **kwargs): self.fields["name_de_field"].initial = self.instance.full_name_de self.fields["name_en_field"].initial = self.instance.full_name_en + self.fields["main_language"].choices = settings.LANGUAGES + self.fields["general_questionnaires"].queryset = ( Questionnaire.objects.general_questionnaires() .filter(Q(visibility=Questionnaire.Visibility.EDITORS) | Q(contributions__evaluation=self.instance)) diff --git a/evap/contributor/tests/test_views.py b/evap/contributor/tests/test_views.py index 7eeb1ae243..d5fc473dfb 100644 --- a/evap/contributor/tests/test_views.py +++ b/evap/contributor/tests/test_views.py @@ -1,4 +1,5 @@ import xlrd +from django.conf import settings from django.core import mail from django.urls import reverse from model_bakery import baker @@ -292,6 +293,25 @@ def test_display_request_buttons(self): self.assertEqual(page.body.decode().count("Request changes"), 0) self.assertEqual(page.body.decode().count("Request creation of new account"), 2) + def test_contributor_evaluation_edit_main_language(self): + self.evaluation.main_language = "en" + self.evaluation.save() + + response = self.app.get(self.url, user=self.editor, status=200) + form = response.forms["evaluation-form"] + + with self.assertRaises(ValueError): + form["main_language"] = "x" + + for lang, __ in settings.LANGUAGES: + form["main_language"] = lang + form.submit(name="operation", value="save") + self.assertEqual(Evaluation.objects.get().main_language, lang) + + with self.assertRaises(ValueError): + form["main_language"] = "some_other_wrong_value" + form.submit(name="operation", value="save") + class TestContributorResultsExportView(WebTest): @classmethod diff --git a/evap/evaluation/migrations/0148_evaluation_main_language.py b/evap/evaluation/migrations/0148_evaluation_main_language.py new file mode 100644 index 0000000000..8f6fe74922 --- /dev/null +++ b/evap/evaluation/migrations/0148_evaluation_main_language.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.3 on 2024-11-25 20:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("evaluation", "0147_unusable_password_default"), + ] + + operations = [ + migrations.AddField( + model_name="evaluation", + name="main_language", + field=models.CharField( + blank=True, + choices=[("en", "English"), ("de", "Deutsch"), ("", "undecided")], + default="x", + max_length=2, + verbose_name="main language", + ), + ), + ] diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 8e95bf5881..e093f1ce9c 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -425,6 +425,15 @@ class State(models.IntegerChoices): name_en = models.CharField(max_length=1024, verbose_name=_("name (english)"), blank=True) name = translate(en="name_en", de="name_de") + # questionaire is shown in this language per default + main_language = models.CharField( + max_length=2, + verbose_name=_("main language"), + blank=True, + default="x", + choices=settings.LANGUAGES + [("x", _("undecided"))], + ) + # defines how large the influence of this evaluation's grade is on the total grade of its course weight = models.PositiveSmallIntegerField(verbose_name=_("weight"), default=1) diff --git a/evap/staff/forms.py b/evap/staff/forms.py index e41b01aa6d..91c4796d7c 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -367,6 +367,7 @@ class Meta: "course", "name_de", "name_en", + "main_language", "weight", "allow_editors_to_edit", "is_rewarded", diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index cd7132bc5a..c1ef408acf 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -1920,6 +1920,41 @@ def test_evaluation_create(self): form.submit() self.assertEqual(Evaluation.objects.get().name_de, "lfo9e7bmxp1xi") + def _set_valid_form(self, form): + form["course"] = self.course.pk + form["name_de"] = "lfo9e7bmxp1xi" + form["name_en"] = "asdf" + form["vote_start_datetime"] = "2014-01-01 00:00:00" + form["vote_end_date"] = "2099-01-01" + form["general_questionnaires"] = [self.q1.pk] + form["wait_for_grade_upload_before_publishing"] = True + + form["contributions-TOTAL_FORMS"] = 1 + form["contributions-INITIAL_FORMS"] = 0 + form["contributions-MAX_NUM_FORMS"] = 5 + form["contributions-0-evaluation"] = "" + form["contributions-0-contributor"] = self.manager.pk + form["contributions-0-questionnaires"] = [self.q2.pk] + form["contributions-0-order"] = 0 + form["contributions-0-role"] = Contribution.Role.EDITOR + form["contributions-0-textanswer_visibility"] = Contribution.TextAnswerVisibility.GENERAL_TEXTANSWERS + + def test_evaluation_create_main_language(self): + """ + Tests the evaluation creation view with one valid and one invalid input dataset. + """ + response = self.app.get(self.url_for_semester, user=self.manager, status=200) + form = response.forms["evaluation-form"] + self._set_valid_form(form) + + with self.assertRaises(ValueError): + form["main_language"] = "some_wrong_value" + form.submit() + + form["main_language"] = "x" + form.submit() + self.assertEqual(Evaluation.objects.get().main_language, "x") + class TestEvaluationCopyView(WebTestStaffMode): @classmethod From 1e01a20e4f88d08a1b5cff1df1c28b22db18e84a Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 2 Dec 2024 21:14:20 +0100 Subject: [PATCH 2/6] Prevent approving evaluations without a valid main language --- evap/evaluation/models.py | 13 +++++++++++-- evap/evaluation/tests/test_models.py | 17 +++++++++++++++++ evap/staff/forms.py | 7 +++++++ evap/staff/tests/test_views.py | 16 +++++++++++++--- evap/staff/views.py | 14 ++++++++------ 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index e093f1ce9c..19d62d3b94 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -633,6 +633,10 @@ def is_in_evaluation_period(self): def general_contribution_has_questionnaires(self): return self.general_contribution and self.general_contribution.questionnaires.count() > 0 + @property + def has_valid_main_language(self): + return self.main_language != "x" + @property def all_contributions_have_questionnaires(self): if is_prefetched(self, "contributions"): @@ -768,7 +772,12 @@ def can_publish_rating_results(self): def ready_for_editors(self): pass - @transition(field=state, source=State.PREPARED, target=State.EDITOR_APPROVED) + @transition( + field=state, + source=State.PREPARED, + target=State.EDITOR_APPROVED, + conditions=[lambda self: self.has_valid_main_language], + ) def editor_approve(self): pass @@ -776,7 +785,7 @@ def editor_approve(self): field=state, source=[State.NEW, State.PREPARED, State.EDITOR_APPROVED], target=State.APPROVED, - conditions=[lambda self: self.general_contribution_has_questionnaires], + conditions=[lambda self: self.general_contribution_has_questionnaires and self.has_valid_main_language], ) def manager_approve(self): pass diff --git a/evap/evaluation/tests/test_models.py b/evap/evaluation/tests/test_models.py index e74f873c6e..89fe33a212 100644 --- a/evap/evaluation/tests/test_models.py +++ b/evap/evaluation/tests/test_models.py @@ -6,6 +6,7 @@ from django.core.cache import caches from django.core.exceptions import ValidationError from django.test import override_settings +from django_fsm import TransitionNotAllowed from model_bakery import baker from evap.evaluation.models import ( @@ -537,6 +538,22 @@ def test_textanswer_review_state(self): evaluation.TextAnswerReviewState.REVIEWED, ) + def test_approve_without_main_language(self): + evaluation = baker.make( + Evaluation, + ) + evaluation.general_contribution.questionnaires.set([baker.make(Questionnaire)]) + + with self.assertRaises(TransitionNotAllowed): + evaluation.editor_approve() + + with self.assertRaises(TransitionNotAllowed): + evaluation.manager_approve() + + evaluation.main_language = "en" + evaluation.save() + evaluation.manager_approve() + class TestCourse(TestCase): def test_can_be_deleted_by_manager(self): diff --git a/evap/staff/forms.py b/evap/staff/forms.py index 91c4796d7c..6ced3c6ff4 100644 --- a/evap/staff/forms.py +++ b/evap/staff/forms.py @@ -385,6 +385,7 @@ class Meta: def __init__(self, *args, **kwargs): semester = kwargs.pop("semester", None) + self.operation = kwargs.pop("operation", "save") super().__init__(*args, **kwargs) self.fields["course"].queryset = Course.objects.filter(semester=semester) @@ -447,6 +448,12 @@ def clean_weight(self): self.add_error("weight", _("At least one evaluation of the course must have a weight greater than 0.")) return weight + def clean_main_language(self): + main_language = self.cleaned_data.get("main_language") + if self.operation == "approve" and main_language == "x": + self.add_error("main_language", _("You have to set a main language to approve this evaluation.")) + return main_language + def clean(self): super().clean() diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py index c1ef408acf..d424c8b9d8 100644 --- a/evap/staff/tests/test_views.py +++ b/evap/staff/tests/test_views.py @@ -1940,9 +1940,6 @@ def _set_valid_form(self, form): form["contributions-0-textanswer_visibility"] = Contribution.TextAnswerVisibility.GENERAL_TEXTANSWERS def test_evaluation_create_main_language(self): - """ - Tests the evaluation creation view with one valid and one invalid input dataset. - """ response = self.app.get(self.url_for_semester, user=self.manager, status=200) form = response.forms["evaluation-form"] self._set_valid_form(form) @@ -2384,6 +2381,19 @@ def test_state_change_log_translated(self, trans): response = self.app.get(self.url, user=self.manager) self.assertContains(response, "TRANSLATED-state: TRANSLATED-new → TRANSLATED-prepared") + def test_evaluation_confirm_without_main_language(self): + response = self.app.get(self.url, user=self.manager, status=200) + form = response.forms["evaluation-form"] + + form["main_language"] = "x" + response = form.submit("operation", value="approve") + self.assertContains(response, "You have to set a main language to approve this evaluation.") + + self.assertNotEqual(Evaluation.objects.first().state, self.evaluation.State.APPROVED) + + form["main_language"] = "en" + form.submit("operation", value="approve") + class TestEvaluationDeleteView(WebTestStaffMode): csrf_checks = False diff --git a/evap/staff/views.py b/evap/staff/views.py index 16e58022c7..ee8a2c6c6f 100644 --- a/evap/staff/views.py +++ b/evap/staff/views.py @@ -1330,17 +1330,19 @@ def notify_reward_points(grantings, **_kwargs): Evaluation, Contribution, formset=ContributionFormset, form=ContributionForm, extra=1 if editable else 0 ) - evaluation_form = EvaluationForm(request.POST or None, instance=evaluation, semester=evaluation.course.semester) + operation = request.POST.get("operation") + + if request.method == "POST" and operation not in ("save", "approve"): + raise SuspiciousOperation("Invalid POST operation") + + evaluation_form = EvaluationForm( + request.POST or None, instance=evaluation, semester=evaluation.course.semester, operation=operation + ) formset = InlineContributionFormset( request.POST or None, instance=evaluation, form_kwargs={"evaluation": evaluation} ) - operation = request.POST.get("operation") - if evaluation_form.is_valid() and formset.is_valid(): - if operation not in ("save", "approve"): - raise SuspiciousOperation("Invalid POST operation") - if not evaluation.can_be_edited_by_manager or evaluation.participations_are_archived: raise SuspiciousOperation("Modifying this evaluation is not allowed.") From e044b8c528d18ae72e8d4a79774ce4b0d967e9a4 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 13 Jan 2025 21:24:44 +0100 Subject: [PATCH 3/6] Add migration for setting main language for existing evaluations --- .../migrations/0148_evaluation_main_language.py | 10 ++++++++-- evap/evaluation/models.py | 1 - 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/evap/evaluation/migrations/0148_evaluation_main_language.py b/evap/evaluation/migrations/0148_evaluation_main_language.py index 8f6fe74922..450407f005 100644 --- a/evap/evaluation/migrations/0148_evaluation_main_language.py +++ b/evap/evaluation/migrations/0148_evaluation_main_language.py @@ -3,6 +3,12 @@ from django.db import migrations, models +def _migrate(apps, schema_editor): + Evaluation = apps.get_model("evaluation", "Evaluation") + for evaluation in Evaluation.objects.filter(state__gte=40): + evaluation.main_language = "de" + evaluation.save() + class Migration(migrations.Migration): dependencies = [ @@ -14,11 +20,11 @@ class Migration(migrations.Migration): model_name="evaluation", name="main_language", field=models.CharField( - blank=True, - choices=[("en", "English"), ("de", "Deutsch"), ("", "undecided")], + choices=[("en", "English"), ("de", "Deutsch"), ("x", "undecided")], default="x", max_length=2, verbose_name="main language", ), ), + migrations.RunPython(_migrate), ] diff --git a/evap/evaluation/models.py b/evap/evaluation/models.py index 19d62d3b94..73ad82ada9 100644 --- a/evap/evaluation/models.py +++ b/evap/evaluation/models.py @@ -429,7 +429,6 @@ class State(models.IntegerChoices): main_language = models.CharField( max_length=2, verbose_name=_("main language"), - blank=True, default="x", choices=settings.LANGUAGES + [("x", _("undecided"))], ) From de1fab2a1415bb06332422f796efc5335f18280c Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 13 Jan 2025 21:27:59 +0100 Subject: [PATCH 4/6] Add select for questionnaire language on vote page --- evap/student/templates/student_vote.html | 149 +++++++++++++---------- evap/student/views.py | 8 +- 2 files changed, 92 insertions(+), 65 deletions(-) diff --git a/evap/student/templates/student_vote.html b/evap/student/templates/student_vote.html index 4002ecc713..3a65c37805 100644 --- a/evap/student/templates/student_vote.html +++ b/evap/student/templates/student_vote.html @@ -51,7 +51,26 @@ {% endif %} -

{{ evaluation.full_name }} ({{ evaluation.course.semester.name }})

+

+ {{ evaluation.full_name }} ({{ evaluation.course.semester.name }}) + +
+
{% trans "Questionnaire language" %}
+
+ {% for language_code, language_name in languages %} + {% csrf_token %} + + {{ language_code|upper }} + + {% endfor %} +
+
+

{% csrf_token %} @@ -78,77 +97,79 @@

{{ evaluation.full_name }} ({{ evaluation.course.semester.name {% endif %} - {% if evaluation_form_group_top %} -
-
- {% translate 'General questions' %} -
-
- {% include 'student_vote_questionnaire_group.html' with questionnaire_group=evaluation_form_group_top textanswers_visible_to=general_contribution_textanswers_visible_to preview=preview %} -
-
- {% endif %} - {% if contributor_form_groups %} -
-
- {% translate 'Questions about the contributors' %} + {% language evaluation_language %} + {% if evaluation_form_group_top %} +
+
+ {% translate 'General questions' %} +
+
+ {% include 'student_vote_questionnaire_group.html' with questionnaire_group=evaluation_form_group_top textanswers_visible_to=general_contribution_textanswers_visible_to preview=preview %} +
-
- {% if not preview %} -
- {% blocktranslate %}Please vote for all contributors you worked with. Click on "I can't give feedback" to skip a person.{% endblocktranslate %} -
- {% endif %} - - {% for contributor, label, form_group, contributor_has_errors, textanswers_visible_to in contributor_form_groups %} -
-
-
- -
+ {% endif %} + {% if contributor_form_groups %} +
+
+ {% translate 'Questions about the contributors' %} +
+
+ {% if not preview %} +
+ {% blocktranslate %}Please vote for all contributors you worked with. Click on "I can't give feedback" to skip a person.{% endblocktranslate %}
-
-
- {% include 'student_vote_questionnaire_group.html' with questionnaire_group=form_group preview=preview textanswers_visible_to=textanswers_visible_to contributor=contributor%} + {% endif %} + + {% for contributor, label, form_group, contributor_has_errors, textanswers_visible_to in contributor_form_groups %} +
+
+
+ +
+
+
+
+ {% include 'student_vote_questionnaire_group.html' with questionnaire_group=form_group preview=preview textanswers_visible_to=textanswers_visible_to contributor=contributor%} +
-
- {% endfor %} -
-
- {% endif %} - {% if evaluation_form_group_bottom %} -
-
- {% translate "General questions" %} -
-
- {% include "student_vote_questionnaire_group.html" with questionnaire_group=evaluation_form_group_bottom textanswers_visible_to=general_contribution_textanswers_visible_to preview=preview %} + {% endfor %} +
-
- {% endif %} - {% if small_evaluation_size_warning and not preview and evaluation.num_voters == 0 %} -
-
- {% translate 'Small number of participants' %} + {% endif %} + {% if evaluation_form_group_bottom %} +
+
+ {% translate "General questions" %} +
+
+ {% include "student_vote_questionnaire_group.html" with questionnaire_group=evaluation_form_group_bottom textanswers_visible_to=general_contribution_textanswers_visible_to preview=preview %} +
-
- {% blocktranslate %}You're the first person taking part in this evaluation. If you want your text answers to be shown to the contributors even if you remain the only person taking part, please check the box below. Otherwise your text answers will be deleted if no other person takes part in the evaluation.{% endblocktranslate %} - -
- - + {% endif %} + {% if small_evaluation_size_warning and not preview and evaluation.num_voters == 0 %} +
+
+ {% translate 'Small number of participants' %} +
+
+ {% blocktranslate %}You're the first person taking part in this evaluation. If you want your text answers to be shown to the contributors even if you remain the only person taking part, please check the box below. Otherwise your text answers will be deleted if no other person takes part in the evaluation.{% endblocktranslate %} + +
+ + +
-
- {% endif %} + {% endif %} + {% endlanguage %} {% if not for_rendering_in_modal %}
diff --git a/evap/student/views.py b/evap/student/views.py index 53eba861e0..63f081e4fa 100644 --- a/evap/student/views.py +++ b/evap/student/views.py @@ -14,6 +14,7 @@ from django.urls import reverse from django.utils.translation import get_language from django.utils.translation import gettext as _ +from django.utils.translation import override from evap.evaluation.auth import participant_required from evap.evaluation.models import NO_ANSWER, Evaluation, RatingAnswerCounter, Semester, TextAnswer, VoteTimestamp @@ -22,6 +23,7 @@ get_evaluations_with_course_result_attributes, textanswers_visible_to, ) +from evap.settings import LANGUAGES from evap.student.forms import QuestionnaireVotingForm from evap.student.models import TextAnswerWarning from evap.student.tools import answer_field_id @@ -204,7 +206,9 @@ def get_vote_page_form_groups(request, evaluation, preview): def render_vote_page(request, evaluation, preview, for_rendering_in_modal=False): - form_groups = get_vote_page_form_groups(request, evaluation, preview) + language = request.GET.get("language", evaluation.main_language) + with override(language): + form_groups = get_vote_page_form_groups(request, evaluation, preview) assert preview or not all(form.is_valid() for form_group in form_groups.values() for form in form_group) @@ -250,6 +254,8 @@ def render_vote_page(request, evaluation, preview, for_rendering_in_modal=False) "for_rendering_in_modal": for_rendering_in_modal, "general_contribution_textanswers_visible_to": textanswers_visible_to(evaluation.general_contribution), "text_answer_warnings": TextAnswerWarning.objects.all(), + "languages": LANGUAGES, + "evaluation_language": language, } return render(request, "student_vote.html", template_data) From 977a4ae6175d1b231e5df1b96841bb593899e9e7 Mon Sep 17 00:00:00 2001 From: Jonathan Weth Date: Mon, 27 Jan 2025 19:54:36 +0100 Subject: [PATCH 5/6] Refactor Sisyphus to ts and drop unnecessary functionality --- evap/static/js/sisyphus.min.js | 13 - evap/static/ts/src/sisyphus.LICENSE | 21 ++ evap/static/ts/src/sisyphus.ts | 309 +++++++++++++++++++++++ evap/student/templates/student_vote.html | 6 +- 4 files changed, 332 insertions(+), 17 deletions(-) delete mode 100644 evap/static/js/sisyphus.min.js create mode 100644 evap/static/ts/src/sisyphus.LICENSE create mode 100644 evap/static/ts/src/sisyphus.ts diff --git a/evap/static/js/sisyphus.min.js b/evap/static/js/sisyphus.min.js deleted file mode 100644 index 2e03b4a94e..0000000000 --- a/evap/static/js/sisyphus.min.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * This is sisyphus release 1.1.2. - * As of 2018-02-12, version 1.1.3 is broken for evap, because of this issue: - * - * https://github.com/simsalabim/sisyphus/issues/148 - * - * The commit - * - * https://github.com/simsalabim/sisyphus/commit/752f6767622030068509ef6cb496188aed5a1926#diff-c34c3b9437c0f2b4f2071ae94197f29fR11 - * - * causes radio button selections to be saved with multiple key/value pairs, which breaks restoring the selection. - */ -!function(t){t.fn.sisyphus=function(e){var i=t.map(this,function(e){return t(e).attr("id")+t(e).attr("name")}).join(),n=Sisyphus.getInstance(i);return n.protect(this,e),n};var e={};e.isAvailable=function(){if("object"==typeof t.jStorage)return!0;try{return localStorage.getItem}catch(e){return!1}},e.set=function(e,i){if("object"==typeof t.jStorage)t.jStorage.set(e,i+"");else try{localStorage.setItem(e,i+"")}catch(n){}},e.get=function(e){if("object"==typeof t.jStorage){var i=t.jStorage.get(e);return i?""+i:i}return localStorage.getItem(e)},e.remove=function(e){"object"==typeof t.jStorage?t.jStorage.deleteKey(e):localStorage.removeItem(e)},Sisyphus=function(){function i(){return{setInstanceIdentifier:function(t){this.identifier=t},getInstanceIdentifier:function(){return this.identifier},setInitialOptions:function(i){var n={excludeFields:[],customKeySuffix:"",locationBased:!1,timeout:0,autoRelease:!0,onSave:function(){},onBeforeRestore:function(){},onRestore:function(){},onRelease:function(){}};this.options=this.options||t.extend(n,i),this.browserStorage=e},setOptions:function(e){this.options=this.options||this.setInitialOptions(e),this.options=t.extend(this.options,e)},protect:function(e,i){this.setOptions(i),e=e||{};var a=this;if(this.targets=this.targets||[],this.href=a.options.name?a.options.name:location.hostname+location.pathname+location.search+location.hash,this.targets=t.merge(this.targets,e),this.targets=t.unique(this.targets),this.targets=t(this.targets),!this.browserStorage.isAvailable())return!1;var o=a.options.onBeforeRestore.call(a);if((void 0===o||o)&&a.restoreAllData(),this.options.autoRelease&&a.bindReleaseData(),!n.started[this.getInstanceIdentifier()])if(a.isCKEditorPresent())var s=setInterval(function(){CKEDITOR.isLoaded&&(clearInterval(s),a.bindSaveData(),n.started[a.getInstanceIdentifier()]=!0)},100);else a.bindSaveData(),n.started[a.getInstanceIdentifier()]=!0},isCKEditorPresent:function(){return this.isCKEditorExists()?(CKEDITOR.isLoaded=!1,CKEDITOR.on("instanceReady",function(){CKEDITOR.isLoaded=!0}),!0):!1},isCKEditorExists:function(){return"undefined"!=typeof CKEDITOR},findFieldsToProtect:function(t){return t.find(":input").not(":submit").not(":reset").not(":button").not(":file").not(":password").not(":disabled").not("[readonly]")},bindSaveData:function(){var e=this;e.options.timeout&&e.saveDataByTimeout(),e.targets.each(function(){var i=t(this).attr("id")+t(this).attr("name");e.findFieldsToProtect(t(this)).each(function(){if(-1!==t.inArray(this,e.options.excludeFields))return!0;var n=t(this),a=(e.options.locationBased?e.href:"")+i+n.attr("name")+e.options.customKeySuffix;(n.is(":text")||n.is("textarea"))&&(e.options.timeout||e.bindSaveDataImmediately(n,a)),e.bindSaveDataOnChange(n)})})},saveAllData:function(){var e=this;e.targets.each(function(){var i=t(this).attr("id")+t(this).attr("name"),n={};e.findFieldsToProtect(t(this)).each(function(){var a=t(this);if(-1!==t.inArray(this,e.options.excludeFields)||void 0===a.attr("name"))return!0;var o=(e.options.locationBased?e.href:"")+i+a.attr("name")+e.options.customKeySuffix,s=a.val();if(a.is(":checkbox")){if(-1!==a.attr("name").indexOf("[")){if(n[a.attr("name")]===!0)return;s=[],t("[name='"+a.attr("name")+"']:checked").each(function(){s.push(t(this).val())}),n[a.attr("name")]=!0}else s=a.is(":checked");e.saveToBrowserStorage(o,s,!1)}else if(a.is(":radio"))a.is(":checked")&&(s=a.val(),e.saveToBrowserStorage(o,s,!1));else if(e.isCKEditorExists()){var r;(r=CKEDITOR.instances[a.attr("name")]||CKEDITOR.instances[a.attr("id")])?(r.updateElement(),e.saveToBrowserStorage(o,a.val(),!1)):e.saveToBrowserStorage(o,s,!1)}else e.saveToBrowserStorage(o,s,!1)})}),e.options.onSave.call(e)},restoreAllData:function(){var e=this,i=!1;e.targets.each(function(){var n=t(this),a=t(this).attr("id")+t(this).attr("name");e.findFieldsToProtect(n).each(function(){if(-1!==t.inArray(this,e.options.excludeFields))return!0;var n=t(this),o=(e.options.locationBased?e.href:"")+a+n.attr("name")+e.options.customKeySuffix,s=e.browserStorage.get(o);null!==s&&(e.restoreFieldsData(n,s),i=!0)})}),i&&e.options.onRestore.call(e)},restoreFieldsData:function(t,e){return void 0===t.attr("name")?!1:void(t.is(":checkbox")&&"false"!==e&&-1===t.attr("name").indexOf("[")?t.attr("checked","checked"):t.is(":checkbox")&&"false"===e&&-1===t.attr("name").indexOf("[")?t.removeAttr("checked"):t.is(":radio")?t.val()===e&&t.attr("checked","checked"):-1===t.attr("name").indexOf("[")?t.val(e):(e=e.split(","),t.val(e)))},bindSaveDataImmediately:function(t,e){var i=this;if("onpropertychange"in t?t.get(0).onpropertychange=function(){i.saveToBrowserStorage(e,t.val())}:t.get(0).oninput=function(){i.saveToBrowserStorage(e,t.val())},this.isCKEditorExists()){var n;(n=CKEDITOR.instances[t.attr("name")]||CKEDITOR.instances[t.attr("id")])&&n.document.on("keyup",function(){n.updateElement(),i.saveToBrowserStorage(e,t.val())})}},saveToBrowserStorage:function(t,e,i){i=void 0===i?!0:i,this.browserStorage.set(t,e),i&&""!==e&&this.options.onSave.call(this)},bindSaveDataOnChange:function(t){var e=this;t.change(function(){e.saveAllData()})},saveDataByTimeout:function(){var t=this,e=t.targets;setTimeout(function(){function e(){t.saveAllData(),setTimeout(e,1e3*t.options.timeout)}return e}(e),1e3*t.options.timeout)},bindReleaseData:function(){var e=this;e.targets.each(function(){var i=t(this),n=i.attr("id")+i.attr("name");t(this).bind("submit reset",function(){e.releaseData(n,e.findFieldsToProtect(i))})})},manuallyReleaseData:function(){var e=this;e.targets.each(function(){var i=t(this),n=i.attr("id")+i.attr("name");e.releaseData(n,e.findFieldsToProtect(i))})},releaseData:function(e,i){var a=!1,o=this;n.started[o.getInstanceIdentifier()]=!1,i.each(function(){if(-1!==t.inArray(this,o.options.excludeFields))return!0;var i=t(this),n=(o.options.locationBased?o.href:"")+e+i.attr("name")+o.options.customKeySuffix;o.browserStorage.remove(n),a=!0}),a&&o.options.onRelease.call(o)}}}var n={instantiated:[],started:[]};return{getInstance:function(t){return n.instantiated[t]||(n.instantiated[t]=i(),n.instantiated[t].setInstanceIdentifier(t),n.instantiated[t].setInitialOptions()),t?n.instantiated[t]:n.instantiated[t]},free:function(){return n={instantiated:[],started:[]},null},version:"1.1.2"}}()}(jQuery); diff --git a/evap/static/ts/src/sisyphus.LICENSE b/evap/static/ts/src/sisyphus.LICENSE new file mode 100644 index 0000000000..d53be7e3d0 --- /dev/null +++ b/evap/static/ts/src/sisyphus.LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2011-2013 Alexander Kaupanin https://github.com/simsalabim +Copyright (c) 2024 Jonathan Weth + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/evap/static/ts/src/sisyphus.ts b/evap/static/ts/src/sisyphus.ts new file mode 100644 index 0000000000..db049c46aa --- /dev/null +++ b/evap/static/ts/src/sisyphus.ts @@ -0,0 +1,309 @@ +/** + * Plugin developed to save html forms data to LocalStorage to restore them after browser crashes, tabs closings + * and other disasters. + * + * @author Alexander Kaupanin + * @author Jonathan Weth + */ + +interface SisyphusOptions { + name?: string; + excludeFields: string[]; + customKeySuffix: string; + locationBased: boolean; + timeout: number; + autoRelease: boolean; + onSave: (arg0: Sisyphus) => void; + onBeforeRestore: (arg0: Sisyphus) => boolean | void; + onRestore: (arg0: Sisyphus) => void; + onRelease: (arg0: Sisyphus) => void; +} + +export class Sisyphus { + private readonly identifier: string; + private options: SisyphusOptions; + private readonly href: string; + private readonly targets: NodeListOf; + + constructor(identifier: string, options: SisyphusOptions) { + const defaults = { + excludeFields: [], + customKeySuffix: "", + locationBased: false, + timeout: 0, + autoRelease: true, + onSave: function () {}, + onBeforeRestore: function () {}, + onRestore: function () {}, + onRelease: function () {}, + }; + + this.identifier = identifier; + this.options = { ...defaults, ...options }; + + this.targets = document.querySelectorAll(this.identifier); + if (this.options.name) { + this.href = this.options.name; + } else { + this.href = location.hostname + location.pathname; + } + + const callback_result = this.options.onBeforeRestore(this); + if (callback_result === undefined || callback_result) { + this.restoreAllData(); + } + + if (this.options.autoRelease) { + this.bindReleaseData(); + } + + this.bindSaveData(); + } + + findFieldsToProtect(target: HTMLFormElement): Array { + return Array.of(...target.elements).filter((el: Element) => { + if ( + el instanceof HTMLInputElement && + ["submit", "reset", "button", "file", "password", "hidden"].includes(el.type.toLowerCase()) + ) { + return false; + } + if (["BUTTON", "FIELDSET", "OBJECT", "OUTPUT"].includes(el.tagName)) { + return false; + } + return true; + }); + } + + getExcludeFields(): Element[] { + return this.options.excludeFields.flatMap(selector => Array.from(document.querySelectorAll(selector))); + } + + getFormIdAndName(target: HTMLFormElement) { + return target.id + target.name; + } + + getPrefix(target: HTMLFormElement, field: Element) { + return ( + (this.options.locationBased ? this.href : "") + + this.getFormIdAndName(target) + + (field.getAttribute("name") || "") + + this.options.customKeySuffix + ); + } + + /** + * Bind saving data + */ + bindSaveData() { + if (this.options.timeout) { + this.saveDataByTimeout(); + } + + for (const target of this.targets) { + for (const field of this.findFieldsToProtect(target)) { + if (this.getExcludeFields().includes(field)) { + continue; + } + const prefix = this.getPrefix(target, field); + if ( + (field instanceof HTMLInputElement && field.type === "text") || + field instanceof HTMLTextAreaElement + ) { + if (!this.options.timeout) { + this.bindSaveDataImmediately(field, prefix); + } + } + this.bindSaveDataOnChange(field); + } + } + } + + /** + * Save all protected forms data to Local Storage. + * Common method, necessary to not lead astray user firing 'data is saved' when select/checkbox/radio + * is changed and saved, while text field data is saved only by timeout + */ + saveAllData() { + for (const target of this.targets) { + let multiCheckboxCache: { [key: string]: boolean } = {}; + + for (const field of this.findFieldsToProtect(target)) { + if (this.getExcludeFields().includes(field) || !field.getAttribute("name")) { + continue; + } + const prefix = this.getPrefix(target, field); + const fieldType = field.getAttribute("type"); + // @ts-ignore + let value: string | string[] | boolean = field.value; + + if (field instanceof HTMLInputElement && fieldType === "checkbox") { + if (field.name.indexOf("[") !== -1) { + if (multiCheckboxCache[field.name]) { + return; + } + let tempValue: string[] = []; + $("[name='" + field.name + "']:checked").each(function () { + tempValue.push(field.value); + }); + value = tempValue; + multiCheckboxCache[field.name] = true; + } else { + value = field.checked; + } + this.saveToBrowserStorage(prefix, value, false); + } else if (field instanceof HTMLInputElement && fieldType === "radio") { + if (field.checked) { + value = field.value; + this.saveToBrowserStorage(prefix, value, false); + } + } else { + this.saveToBrowserStorage(prefix, value, false); + } + } + } + this.options.onSave(this); + } + + /** + * Restore forms data from Local Storage + */ + restoreAllData() { + let restored = false; + + for (const target of this.targets) { + for (const field of this.findFieldsToProtect(target)) { + if (this.getExcludeFields().includes(field)) { + continue; + } + const resque = localStorage.getItem(this.getPrefix(target, field)); + if (resque !== null) { + this.restoreFieldsData(field, resque); + restored = true; + } + } + } + + if (restored) { + this.options.onRestore(this); + } + } + + /** + * Restore form field data from local storage + */ + restoreFieldsData(field: Element, resque: string) { + if (field.getAttribute("name") === undefined) { + return false; + } + if ( + field instanceof HTMLInputElement && + field.type === "checkbox" && + resque !== "false" && + field.name.indexOf("[") === -1 + ) { + field.checked = true; + } else if ( + field instanceof HTMLInputElement && + field.type === "checkbox" && + resque === "false" && + field.name.indexOf("[") === -1 + ) { + field.checked = false; + } else if (field instanceof HTMLInputElement && field.type === "radio") { + if (field.value === resque) { + field.checked = true; + } + } else if (field instanceof HTMLInputElement && field.name.indexOf("[") === -1) { + field.value = resque; + } else { + // @ts-ignore + field.value = resque.split(","); + } + } + + /** + * Bind immediate saving (on typing/checking/changing) field data to local storage when user fills it + */ + bindSaveDataImmediately(field: HTMLInputElement | HTMLTextAreaElement, prefix: string) { + field.addEventListener("input", () => { + this.saveToBrowserStorage(prefix, field.value); + }); + } + + /** + * Save data to Local Storage and fire callback if defined + */ + saveToBrowserStorage(key: string, value: any, fireCallback?: boolean) { + // if fireCallback is undefined it should be true + fireCallback = fireCallback === undefined ? true : fireCallback; + localStorage.setItem(key, value + ""); + if (fireCallback && value !== "") { + this.options.onSave(this); + } + } + + /** + * Bind saving field data on change + */ + bindSaveDataOnChange(field: Element) { + field.addEventListener("change", () => { + this.saveAllData(); + }); + } + + /** + * Saving (by timeout) field data to local storage when user fills it + */ + saveDataByTimeout() { + setTimeout(() => { + const timeout = () => { + this.saveAllData(); + setTimeout(timeout, this.options.timeout * 1000); + }; + + return timeout; + }, this.options.timeout * 1000); + } + + /** + * Bind release form fields data from local storage on submit/reset form + */ + bindReleaseData() { + for (const target of this.targets) { + const releaseHandler = () => { + this.releaseData(target); + }; + target.addEventListener("submit", releaseHandler); + target.addEventListener("reset", releaseHandler); + } + } + + /** + * Manually release form fields + */ + manuallyReleaseData() { + for (const target of this.targets) { + this.releaseData(target); + } + } + + /** + * Bind release form fields data from local storage on submit/resett form + */ + releaseData(target: HTMLFormElement) { + let released = false; + + for (const field of this.findFieldsToProtect(target)) { + if (this.getExcludeFields().includes(field)) { + continue; + } + localStorage.removeItem(this.getPrefix(target, field)); + released = true; + } + + if (released) { + this.options.onRelease(this); + } + } +} diff --git a/evap/student/templates/student_vote.html b/evap/student/templates/student_vote.html index 3a65c37805..03cf12c9d2 100644 --- a/evap/student/templates/student_vote.html +++ b/evap/student/templates/student_vote.html @@ -194,11 +194,10 @@

{% block additional_javascript %} {% if not preview %} - - {{ text_answer_warnings|text_answer_warning_trigger_strings|json_script:'text-answer-warnings' }}