From e135cc588a194aa27e6d55ca883faffd722de014 Mon Sep 17 00:00:00 2001 From: Mike Allaway Date: Thu, 29 Feb 2024 15:25:27 +0000 Subject: [PATCH] cod-milinp-5 series of change --- .gitignore | 1 + django/account/admin.py | 7 +- django/account/forms.py | 13 +- ...er_options_remove_user_default_language.py | 21 +++ django/account/models.py | 6 +- django/account/views.py | 17 +-- django/core/static/css/custom.css | 49 +++++- django/core/static/css/custom_large.css | 9 ++ django/core/static/js/main.js | 8 +- django/exercises/admin.py | 19 +++ ...ontsize_exercise_collaborators_and_more.py | 142 ++++++++++++++++++ django/exercises/models.py | 116 +++++++++++++- .../templates/exercises/exercise-add.html | 21 +++ .../templates/exercises/exercise-detail.html | 43 ++++-- .../templates/exercises/exercise-edit.html | 18 +++ .../templates/exercises/exercise-list.html | 6 +- .../snippets/exercise-content-edit-link.html | 10 +- .../fill-in-the-blank.html | 2 +- .../exercise-detail-formats/image-match.html | 114 ++++++++++---- .../multiple-choice.html | 11 +- .../sentence-builder.html | 11 +- .../exercise-detail-formats/translation.html | 17 ++- django/exercises/views.py | 62 +++++--- 23 files changed, 594 insertions(+), 129 deletions(-) create mode 100644 django/account/migrations/0006_alter_user_options_remove_user_default_language.py create mode 100644 django/exercises/migrations/0004_fontsize_exercise_collaborators_and_more.py diff --git a/.gitignore b/.gitignore index 5008438..297403a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ django/static/ node_modules/ private_testdata* outputdata/ +django/general/migrations/ ###################### Django Templated gitignore paths ###################### diff --git a/django/account/admin.py b/django/account/admin.py index 4e15ec3..d0ecbc7 100644 --- a/django/account/admin.py +++ b/django/account/admin.py @@ -2,7 +2,6 @@ from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.contrib.auth.forms import UserChangeForm from django.contrib.auth.models import Group -from account import forms from .models import User, UsersImportSpreadsheet @@ -32,21 +31,18 @@ class UserAdmin(DjangoUserAdmin): Customise the admin interface: User """ - add_form = forms.DashboardUserChangeForm form = UserChangeForm model = User list_display = ['username', 'internal_id_number', 'first_name', 'last_name', - 'email', 'role', - 'default_language', 'is_internal', 'is_active', 'date_joined', 'last_login'] - search_fields = ['username', 'first_name', 'last_name', 'email'] + search_fields = ['username', 'first_name', 'last_name', 'email', 'internal_id_number'] list_filter = ['role', 'is_active', 'is_internal'] filter_horizontal = ('classes',) readonly_fields = ['date_joined', 'last_login', 'is_staff', 'is_superuser'] @@ -64,6 +60,7 @@ class UserAdmin(DjangoUserAdmin): 'last_login') fieldsets = None actions = (delete_users,) + ordering = ['first_name', 'last_name'] def has_add_permission(self, request, obj=None): return False diff --git a/django/account/forms.py b/django/account/forms.py index 1ae1507..4bda7cf 100644 --- a/django/account/forms.py +++ b/django/account/forms.py @@ -2,17 +2,6 @@ from .models import User -class DashboardUserChangeForm(UserChangeForm): - """ - Form to specify fields in the user change form, which is only accessible via the Django admin - It's used in admin.py - """ - - class Meta: - model = User - fields = ('first_name', 'last_name', 'email', 'role', 'default_language', 'classes') - - class PublicUserChangeForm(UserChangeForm): """ Form to specify fields in the user change form, which is accessible through the public website @@ -48,4 +37,4 @@ def __init__(self, *args, **kwargs): class Meta: model = User - fields = ('first_name', 'last_name', 'email', 'default_language', 'classes') + fields = ('first_name', 'last_name', 'email', 'classes') diff --git a/django/account/migrations/0006_alter_user_options_remove_user_default_language.py b/django/account/migrations/0006_alter_user_options_remove_user_default_language.py new file mode 100644 index 0000000..518d723 --- /dev/null +++ b/django/account/migrations/0006_alter_user_options_remove_user_default_language.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.9 on 2024-02-29 15:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0005_alter_user_managers'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ['first_name', 'last_name', 'id']}, + ), + migrations.RemoveField( + model_name='user', + name='default_language', + ), + ] diff --git a/django/account/models.py b/django/account/models.py index d60650f..341c382 100644 --- a/django/account/models.py +++ b/django/account/models.py @@ -60,7 +60,6 @@ class User(AbstractUser): is_internal = models.BooleanField(default=True, help_text='Is internal to the University of Birmingham, e.g. an active UoB student or staff member') internal_id_number = models.CharField(max_length=255, help_text='If internal to the University of Birmingham, please provide a unique ID, e.g. student number, staff number', blank=True) classes = models.ManyToManyField('exercises.SchoolClass', blank=True) - default_language = models.ForeignKey('exercises.Language', on_delete=models.SET_NULL, blank=True, null=True) @property def name(self): @@ -102,7 +101,7 @@ def exercises_todo(self): return exercises def __str__(self): - return self.username + return f'{self.name} - {self.username} ({self.role})' def save(self, *args, **kwargs): # Force email to be lower case @@ -140,3 +139,6 @@ def save(self, *args, **kwargs): Group.objects.get(name='teacher_permissions_group').user_set.add(self) elif self.role.name == 'guest': Group.objects.get(name='guest_permissions_group').user_set.add(self) + + class Meta: + ordering = ['first_name', 'last_name', 'id'] diff --git a/django/account/views.py b/django/account/views.py index a3f2705..3fb46e7 100644 --- a/django/account/views.py +++ b/django/account/views.py @@ -156,19 +156,16 @@ def get_redirect_url(self, *args, **kwargs): # Get/create school class thrugh unique identifiers (year group, language, difficulty) if str(user['year_group']) != 'nan' and str(user['language']) != 'nan' and str(user['difficulty']) != 'nan': - school_class_obj = exercise_models.SchoolClass.objects.get_or_create( + school_class_obj, school_class_is_new = exercise_models.SchoolClass.objects.get_or_create( year_group=exercise_models.YearGroup.objects.get(name=user['year_group']), language=exercise_models.Language.objects.get_or_create(name=user['language'])[0], difficulty=exercise_models.Difficulty.objects.get(name=user['difficulty']) - )[0] - # Add the related class - user_obj.classes.add(school_class_obj) - - # Set default language (if provided, else None) - if str(user['language']) != 'nan': - user_obj.default_language = exercise_models.Language.objects.get_or_create(name=user['language'])[0] - else: - user_obj.default_language = None + ) + # Add/remove the related class from user + if 'remove_from_class' in user and str(user['remove_from_class']).lower() == 'y': + user_obj.classes.remove(school_class_obj) + else: + user_obj.classes.add(school_class_obj) # Add additional user data user_obj.internal_id_number = str(user['internal_id_number']).replace('.0', '') if str(user['internal_id_number']) != 'nan' else '' diff --git a/django/core/static/css/custom.css b/django/core/static/css/custom.css index fca3088..1465e07 100755 --- a/django/core/static/css/custom.css +++ b/django/core/static/css/custom.css @@ -603,28 +603,38 @@ main { } .exerciseformat-showanswer { - color: #505050; + color: #1779C5; font-size: 0.95em; display: none; } .exerciseformat-showanswer label { cursor: pointer; + font-size: 1.1em; } .exerciseformat-showanswer label:hover { opacity: 0.9; } +.exerciseformat-showanswer label i { + font-size: 1.2em; +} + .exerciseformat-showanswer .answer { display: none; padding: 0.5em; margin-bottom: 1em; - background: #505050; - color: #EEE; + background: #1779C5; + color: #FFF; text-align: left; } +.exerciseformat-showanswer .answer img { + max-width: 10em; + display: block; +} + .exerciseformat-showanswer .answer-feedback { margin-top: 1.3em; } @@ -773,10 +783,41 @@ main { text-align: center; } + +/* Exercise: Detail: Format: ImageMatch (Reversed) */ + +.exerciseformat-imagematchreversed { + margin: 5em 0; + overflow: hidden; + padding-bottom: 1em; + vertical-align: top; +} + +.exerciseformat-imagematchreversed-title { + padding: 0.5em 0; + font-weight: bold; + font-size: 1.5em; +} + +.exerciseformat-imagematchreversed-image { +} + +.exerciseformat-imagematchreversed input { + display: block; + margin-bottom: 0.5em; + scale: 1.5; +} + +.exerciseformat-imagematchreversed img { + width: 100%; + border: 1px solid #DDD; + cursor: zoom-in; +} + /* Exercise: Detail: Format: MultipleChoice */ .exerciseformat-multiplechoice { - margin: 4em auto 10em auto; + margin: 3rem auto 7rem auto; } .exerciseformat-multiplechoice-question { diff --git a/django/core/static/css/custom_large.css b/django/core/static/css/custom_large.css index acf5780..a8a7b65 100755 --- a/django/core/static/css/custom_large.css +++ b/django/core/static/css/custom_large.css @@ -128,6 +128,15 @@ display: inline-block; width: calc(25% - 3.3em); } + + /* Exercise: Detail: Format: ImageMatch (Reversed) */ + + .exerciseformat-imagematchreversed-image { + display: inline-block; + margin: 1em; + width: calc(25% - 2.3em); + vertical-align: top; + } /* Exercise: Detail: Format: Translation */ diff --git a/django/core/static/js/main.js b/django/core/static/js/main.js index 9405db1..c192495 100644 --- a/django/core/static/js/main.js +++ b/django/core/static/js/main.js @@ -1,5 +1,5 @@ $(document).ready(function() { - + // Popup // Show $('.popup-show').on('click', function() { @@ -11,4 +11,10 @@ $(document).ready(function() { $(this).closest('.popup').hide(); }); + // Filter select lists + // "Owned By" and "Collaborators" lists to hide students and guests + $('#id_owned_by option, #id_collaborators option').each(function(){ + if ($(this).text().includes('(student)') || $(this).text().includes('(guest)')) $(this).remove(); + }); + }); \ No newline at end of file diff --git a/django/exercises/admin.py b/django/exercises/admin.py index a6d3704..7d8f9b9 100644 --- a/django/exercises/admin.py +++ b/django/exercises/admin.py @@ -118,6 +118,24 @@ def has_delete_permission(self, request, obj=None): return False +class FontSizeAdminView(admin.ModelAdmin): + """ + Customise the admin interface: FontSize + """ + list_display = ('name', 'size_em') + list_display_links = ('name',) + search_fields = ('name', 'size_em') + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + class ExerciseAdminView(admin.ModelAdmin): """ Customise the admin interface: Exercise @@ -186,6 +204,7 @@ def has_delete_permission(self, request, obj=None): admin.site.register(models.SchoolClass, SchoolClassAdminView) admin.site.register(models.Theme, ThemeAdminView) admin.site.register(models.Difficulty, DifficultyAdminView) +admin.site.register(models.FontSize, FontSizeAdminView) admin.site.register(models.Exercise, ExerciseAdminView) admin.site.register(models.SchoolClassAlertExercise, SchoolClassAlertExerciseAdminView) diff --git a/django/exercises/migrations/0004_fontsize_exercise_collaborators_and_more.py b/django/exercises/migrations/0004_fontsize_exercise_collaborators_and_more.py new file mode 100644 index 0000000..0454514 --- /dev/null +++ b/django/exercises/migrations/0004_fontsize_exercise_collaborators_and_more.py @@ -0,0 +1,142 @@ +# Generated by Django 4.2.9 on 2024-02-29 15:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('exercises', '0003_exercise_instructions_image_width_percent_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='FontSize', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('size_em', models.FloatField(help_text='Size of font (measured in em) as a decimal number. 1.0 is considered default/medium.')), + ], + options={ + 'ordering': ['size_em', 'name', 'id'], + }, + ), + migrations.AddField( + model_name='exercise', + name='collaborators', + field=models.ManyToManyField(blank=True, help_text='Persons who can also manage this exercise, in addition to the owner', related_name='exercises', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='exercise', + name='exercise_format_reverse_image_match', + field=models.BooleanField(default=False, help_text='Reverse the layout of this image match exercise, so that the student must select the image that matches the word instead of the word that matches the image.', verbose_name='reverse image match'), + ), + migrations.AddField( + model_name='exercise', + name='instructions_image_url', + field=models.URLField(blank=True, help_text='(Optional) Include a URL/link to an existing image on the internet, instead of needing to download and upload it using the above file upload facility.', null=True), + ), + migrations.AddField( + model_name='exerciseformatimagematch', + name='correct_answer_feedback_audio', + field=models.FileField(blank=True, help_text='(Optional) Provide feedback about the correct answer (if relevant) to help aid student learning, using an audio file alongside or instead of feedback text. Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio'), + ), + migrations.AddField( + model_name='exerciseformatimagematch', + name='image_url', + field=models.URLField(blank=True, help_text='(Optional) Include a URL/link to an existing image on the internet, instead of needing to download and upload it using the above file upload facility.', null=True), + ), + migrations.AddField( + model_name='exerciseformatmultiplechoice', + name='correct_answer_feedback_audio', + field=models.FileField(blank=True, help_text='(Optional) Provide feedback about the correct answer (if relevant) to help aid student learning, using an audio file alongside or instead of feedback text. Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio'), + ), + migrations.AddField( + model_name='exerciseformatsentencebuilder', + name='correct_answer_feedback_audio', + field=models.FileField(blank=True, help_text='(Optional) Provide feedback about the correct answer (if relevant) to help aid student learning, using an audio file alongside or instead of feedback text. Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio'), + ), + migrations.AddField( + model_name='exerciseformattranslation', + name='correct_answer_feedback_audio', + field=models.FileField(blank=True, help_text='(Optional) Provide feedback about the correct answer (if relevant) to help aid student learning, using an audio file alongside or instead of feedback text. Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio'), + ), + migrations.AddField( + model_name='exerciseformattranslation', + name='translation_source_image_url', + field=models.URLField(blank=True, help_text='(Optional) Include a URL/link to an existing image on the internet, instead of needing to download and upload it using the above file upload facility.', null=True, verbose_name='URL to image of source text'), + ), + migrations.AlterField( + model_name='exercise', + name='created_by', + field=models.ForeignKey(blank=True, help_text='The person who originally created this exercise', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='exercise_created_by', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='exercise', + name='owned_by', + field=models.ForeignKey(blank=True, help_text='The person who is mainly responsible for managing this exercise', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='exercise_owned_by', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='exerciseformatfillintheblank', + name='source_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio'), + ), + migrations.AlterField( + model_name='exerciseformatimagematch', + name='image', + field=models.ImageField(blank=True, help_text='Optional if providing a URL to an image below, otherwise required.', null=True, upload_to='exercises-exerciseformat-imagematch'), + ), + migrations.AlterField( + model_name='exerciseformatmultiplechoice', + name='option_a_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio', verbose_name='Option A (audio)'), + ), + migrations.AlterField( + model_name='exerciseformatmultiplechoice', + name='option_b_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio', verbose_name='Option B (audio)'), + ), + migrations.AlterField( + model_name='exerciseformatmultiplechoice', + name='option_c_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio', verbose_name='Option C (audio)'), + ), + migrations.AlterField( + model_name='exerciseformatmultiplechoice', + name='option_d_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio', verbose_name='Option D (audio)'), + ), + migrations.AlterField( + model_name='exerciseformatmultiplechoice', + name='option_e_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio', verbose_name='Option E (audio)'), + ), + migrations.AlterField( + model_name='exerciseformatmultiplechoice', + name='option_f_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio', verbose_name='Option F (audio)'), + ), + migrations.AlterField( + model_name='exerciseformatmultiplechoice', + name='question_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio'), + ), + migrations.AlterField( + model_name='exerciseformatsentencebuilder', + name='sentence_source', + field=models.TextField(blank=True, help_text='(Optional) Provide an original source text in one language, which will be translated below', null=True, verbose_name='source text'), + ), + migrations.AlterField( + model_name='exerciseformatsentencebuilder', + name='sentence_source_audio', + field=models.FileField(blank=True, help_text='(Optional) Record your audio clip and then upload the file here', null=True, upload_to='exercises-exercise-audio', verbose_name='source audio'), + ), + migrations.AddField( + model_name='exercise', + name='font_size', + field=models.ForeignKey(blank=True, help_text='(Optional) Set size of all font in exercise. Leave blank for a default font size.', null=True, on_delete=django.db.models.deletion.RESTRICT, to='exercises.fontsize'), + ), + ] diff --git a/django/exercises/models.py b/django/exercises/models.py index c9dacf9..9c6dfdd 100644 --- a/django/exercises/models.py +++ b/django/exercises/models.py @@ -3,12 +3,17 @@ from account.models import User, UserRole from datetime import date import random +import re + +AUDIO_RECORD_LINK = ' Record your audio clip and then upload the file here' OPTIONAL_HELP_TEXT = "(Optional)" OPTIONAL_IF_AUDIO_HELP_TEXT = "Optional if supplying audio instead, otherwise required" -AUDIO_HELP_TEXT = OPTIONAL_HELP_TEXT + " Record your audio clip and then upload the file here" +AUDIO_HELP_TEXT = OPTIONAL_HELP_TEXT + AUDIO_RECORD_LINK AUDIO_UPLOAD_PATH = "exercises-exercise-audio" +IMAGE_URL_HELP_TEXT = "Include a URL/link to an existing image on the internet, instead of needing to download and upload it using the above file upload facility." CORRECT_ANSWER_FEEDBACK_HELP_TEXT = OPTIONAL_HELP_TEXT + " Provide feedback about the correct answer (if relevant) to help aid student learning" +CORRECT_ANSWER_FEEDBACK_AUDIO_HELP_TEXT = CORRECT_ANSWER_FEEDBACK_HELP_TEXT + ", using an audio file alongside or instead of feedback text." + AUDIO_RECORD_LINK EXERCISE_ITEM_ORDER_HELP_TEXT = OPTIONAL_HELP_TEXT + " Specify the order you'd like this item to appear on the exercise page. Leave blank to order automatically." @@ -26,6 +31,42 @@ def text_or_audiomsg(text_field, audio_field): return "" +def image_path(image_file, image_url): + """ + Images can be either uploaded via an ImageField or linked to elsewhere on the internet via a URL field. + This function is used by dynamic properties on models for each image/image_url combination to return the path to the image. + """ + + # Prioritise image_file, if available, otherwise return image url (or None if neither is available). + if image_file: + return image_file.url + elif image_url: + return image_url + else: + return None + + +def make_urls_clickable(text): + """ + Find all urls in text and add suitable html tag to make them 'clickable' on the website + """ + # If a valid string with content + if type(text) == str and text != '': + # Regex to find all urls in the provided text + urls = re.findall(r'''(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))''', text) # NOQA + # Loop through all urls found in text + for url in urls: + # Filter out URLs that are already links + before_url = text.split(str(url[0]))[0] + # If there isn't a > or " directly before the url + if len(before_url) == 0 or (len(before_url) > 1 and before_url[-1] not in ['>', '"']): + # Ensure link starts with http + link = url[0] if str(url[0]).startswith('http') else f'https://{url[0]}' + # Add necessary HTML to convert link into + text = text.replace(url[0], f'{url[0]}') + return text + + class YearGroup(models.Model): """ A year group that classes are organised into, e.g. 2022/23, 2023/24, etc. @@ -74,6 +115,21 @@ class Meta: verbose_name_plural = 'difficulties' +class FontSize(models.Model): + """ + A size of font (measured in em) (e.g. Small, Medium, Large) + """ + + name = models.CharField(max_length=255, unique=True) + size_em = models.FloatField(help_text="Size of font (measured in em) as a decimal number. 1.0 is considered default/medium.") + + def __str__(self): + return self.name + + class Meta: + ordering = ['size_em', 'name', 'id'] + + class SchoolClass(models.Model): """ A class of students and teacher(s) within the School @@ -180,31 +236,50 @@ class Exercise(models.Model): name = models.CharField(max_length=255) language = models.ForeignKey(Language, on_delete=models.RESTRICT) exercise_format = models.ForeignKey(ExerciseFormat, on_delete=models.RESTRICT) + exercise_format_reverse_image_match = models.BooleanField(default=False, verbose_name='reverse image match', help_text="Reverse the layout of this image match exercise, so that the student must select the image that matches the word instead of the word that matches the image.") theme = models.ForeignKey(Theme, on_delete=models.SET_NULL, blank=True, null=True, help_text=OPTIONAL_HELP_TEXT) difficulty = models.ForeignKey(Difficulty, on_delete=models.RESTRICT) + font_size = models.ForeignKey(FontSize, on_delete=models.RESTRICT, blank=True, null=True, help_text=OPTIONAL_HELP_TEXT + " Set size of all font in exercise. Leave blank for a default font size.") instructions = models.TextField(blank=True, null=True, help_text=OPTIONAL_HELP_TEXT + " If left blank then the default instructions for this exercise format will be used (suitable for most cases)") instructions_image = models.ImageField(upload_to='exercises-exercise-instructions', blank=True, null=True, help_text=OPTIONAL_HELP_TEXT + " Include an image here to illustrate the instructions for the entire exercise. E.g. if all questions relate to this image.") + instructions_image_url = models.URLField(blank=True, null=True, help_text=f'{OPTIONAL_HELP_TEXT} {IMAGE_URL_HELP_TEXT}') instructions_image_width_percent = models.IntegerField(blank=True, null=True, help_text="Optional. Set the percentage width of the instructions box. Images will fill width of instructions box by default.", verbose_name="Instructions image width (%)") is_a_formal_assessment = models.BooleanField(default=False, help_text="Marking this as a formal assessment (i.e. a test that counts to the student's grade) will put restrictions on this exercise, like preventing students from being able to check answers and only allowing a single attempt") is_published = models.BooleanField(default=True, verbose_name="Published") - owned_by = models.ForeignKey(User, related_name="exercise_owned_by", on_delete=models.SET_NULL, blank=True, null=True, help_text="The only teacher who can manage this exercise") - created_by = models.ForeignKey(User, related_name="exercise_created_by", on_delete=models.SET_NULL, blank=True, null=True, help_text="The teacher who originally created this exercise") + owned_by = models.ForeignKey(User, related_name="exercise_owned_by", on_delete=models.SET_NULL, blank=True, null=True, help_text="The person who is mainly responsible for managing this exercise") + collaborators = models.ManyToManyField(User, blank=True, related_name='exercises', help_text="Persons who can also manage this exercise, in addition to the owner") + created_by = models.ForeignKey(User, related_name="exercise_created_by", on_delete=models.SET_NULL, blank=True, null=True, help_text="The person who originally created this exercise") created_datetime = models.DateTimeField(auto_now_add=True, verbose_name="Created") lastupdated_datetime = models.DateTimeField(auto_now=True, verbose_name="Last Updated") + @property + def instructions_image_path(self): + return image_path(self.instructions_image, self.instructions_image_url) + @property def image_match_label_options(self): """ If this is an ImageMatch exercise: - Return an ordered list of all labels of image match objects that are used in this exercise + Return a randomly ordered list of all labels of image match objects that are used in this exercise """ if self.exercise_format == ExerciseFormat.objects.get(name='Image Match'): labels = [] for image_match_object in ExerciseFormatImageMatch.objects.filter(exercise=self): labels.append(image_match_object.label) - random.shuffle(labels) # important to mix up order + random.shuffle(labels) return labels + @property + def image_match_image_options(self): + """ + If this is a reversed ImageMatch exercise: + Return a randomly ordered list of all image match objects that are used in this exercise + """ + if self.exercise_format == ExerciseFormat.objects.get(name='Image Match'): + images = list(ExerciseFormatImageMatch.objects.filter(exercise=self)) + random.shuffle(images) + return images + @property def items(self): if self.exercise_format.name == "Image Match": @@ -226,6 +301,19 @@ def items(self): def items_count(self): return self.items.count() + @property + def font_size_css(self): + size = self.font_size.size_em if self.font_size else 1.0 + return f'font-size: {size}em;' + + @property + def instructions_processed(self): + return make_urls_clickable(self.instructions) + + @property + def collaborators_list(self): + return ", ".join([str(c.name) for c in self.collaborators.all()]) if self.collaborators.all() else None + def __str__(self): return self.name @@ -246,11 +334,17 @@ class ExerciseFormatImageMatch(models.Model): on_delete=models.CASCADE, blank=True, null=True) - image = models.ImageField(upload_to='exercises-exerciseformat-imagematch') + image = models.ImageField(upload_to='exercises-exerciseformat-imagematch', blank=True, null=True, help_text='Optional if providing a URL to an image below, otherwise required.') + image_url = models.URLField(blank=True, null=True, help_text=f'{OPTIONAL_HELP_TEXT} {IMAGE_URL_HELP_TEXT}') label = models.CharField(max_length=255) correct_answer_feedback = models.TextField(blank=True, null=True, help_text=CORRECT_ANSWER_FEEDBACK_HELP_TEXT) + correct_answer_feedback_audio = models.FileField(upload_to=AUDIO_UPLOAD_PATH, help_text=CORRECT_ANSWER_FEEDBACK_AUDIO_HELP_TEXT, blank=True, null=True) # no order field as these load in random order + @property + def image_path(self): + return image_path(self.image, self.image_url) + def __str__(self): if self.exercise: return f"{self.exercise.name}: {self.label}" @@ -300,6 +394,7 @@ class ExerciseFormatMultipleChoice(models.Model): option_f_is_correct = models.BooleanField(default=False, verbose_name="Option F is correct") correct_answer_feedback = models.TextField(blank=True, null=True, help_text=CORRECT_ANSWER_FEEDBACK_HELP_TEXT) + correct_answer_feedback_audio = models.FileField(upload_to=AUDIO_UPLOAD_PATH, help_text=CORRECT_ANSWER_FEEDBACK_AUDIO_HELP_TEXT, blank=True, null=True) order = models.IntegerField(blank=True, null=True, help_text=EXERCISE_ITEM_ORDER_HELP_TEXT) @property @@ -482,11 +577,12 @@ class ExerciseFormatSentenceBuilder(models.Model): on_delete=models.CASCADE, blank=True, null=True) - sentence_source = models.TextField(help_text=f"Provide an original source text in one language, which will be translated below. {OPTIONAL_IF_AUDIO_HELP_TEXT}", blank=True, null=True, verbose_name='source text') + sentence_source = models.TextField(help_text=f"{OPTIONAL_HELP_TEXT} Provide an original source text in one language, which will be translated below", blank=True, null=True, verbose_name='source text') sentence_source_audio = models.FileField(upload_to=AUDIO_UPLOAD_PATH, help_text=AUDIO_HELP_TEXT, blank=True, null=True, verbose_name='source audio') sentence_translated = models.TextField(help_text='Provide a translated/transcribed sentence of the above source text/audio. The words in this target text will be jumbled and the student will have to rebuild it in the correct order.', verbose_name='target text') sentence_translated_extra_words = models.TextField(help_text='(Optional) Include extra words to show as options to make the exercise more challenging. Separate words with a space, e.g. "car apple tree"', blank=True, null=True, verbose_name='extra words for target text') correct_answer_feedback = models.TextField(blank=True, null=True, help_text=CORRECT_ANSWER_FEEDBACK_HELP_TEXT) + correct_answer_feedback_audio = models.FileField(upload_to=AUDIO_UPLOAD_PATH, help_text=CORRECT_ANSWER_FEEDBACK_AUDIO_HELP_TEXT, blank=True, null=True) order = models.IntegerField(blank=True, null=True, help_text=EXERCISE_ITEM_ORDER_HELP_TEXT) @property @@ -533,10 +629,16 @@ class ExerciseFormatTranslation(models.Model): null=True) translation_source_text = models.TextField(blank=True, null=True, help_text='Optional if provided source image instead, otherwise required', verbose_name='source text') translation_source_image = models.ImageField(upload_to='exercises-exerciseformat-translation', blank=True, null=True, help_text='Optional if provided source text instead, otherwise required', verbose_name='image of source text') + translation_source_image_url = models.URLField(blank=True, null=True, help_text=f'{OPTIONAL_HELP_TEXT} {IMAGE_URL_HELP_TEXT}', verbose_name='URL to image of source text') correct_translation = models.TextField(verbose_name='target text') correct_answer_feedback = models.TextField(blank=True, null=True, help_text=CORRECT_ANSWER_FEEDBACK_HELP_TEXT) + correct_answer_feedback_audio = models.FileField(upload_to=AUDIO_UPLOAD_PATH, help_text=CORRECT_ANSWER_FEEDBACK_AUDIO_HELP_TEXT, blank=True, null=True) order = models.IntegerField(blank=True, null=True, help_text=EXERCISE_ITEM_ORDER_HELP_TEXT) + @property + def translation_source_image_path(self): + return image_path(self.translation_source_image, self.translation_source_image_url) + def __str__(self): if self.exercise: return f"{self.exercise.name}: {self.correct_translation[0:40]}" diff --git a/django/exercises/templates/exercises/exercise-add.html b/django/exercises/templates/exercises/exercise-add.html index 3305b48..44599ec 100644 --- a/django/exercises/templates/exercises/exercise-add.html +++ b/django/exercises/templates/exercises/exercise-add.html @@ -12,4 +12,25 @@

Add New Exercise

+ + + {% endblock %} \ No newline at end of file diff --git a/django/exercises/templates/exercises/exercise-detail.html b/django/exercises/templates/exercises/exercise-detail.html index 358c94f..522d2b7 100644 --- a/django/exercises/templates/exercises/exercise-detail.html +++ b/django/exercises/templates/exercises/exercise-detail.html @@ -75,7 +75,7 @@ - {% if exercise.owned_by == user %} + {% if exercise.owned_by == user or user in exercise.collaborators.all %} Edit @@ -88,20 +88,21 @@
{% if exercise.instructions %} - {{ exercise.instructions | safe | linebreaks }} + {{ exercise.instructions_processed | safe | linebreaks }} {% else %} {{ exercise.exercise_format.instructions | safe | linebreaks }} {% endif %} - {% if exercise.instructions_image %} - exercise instructions image + {% if exercise.instructions_image_path %} + exercise instructions image {% endif %}
{% if user.is_staff %}
- Created by: {{ exercise.created_by.name }} - Owned by: {{ exercise.owned_by.name }} + Created by: {{ exercise.created_by.name }} + Owned by: {{ exercise.owned_by.name }} + {% if exercise.collaborators_list %}Collaborators: {{ exercise.collaborators_list }}{% endif %}
{% endif %} {% if exercise.is_a_formal_assessment %} @@ -112,12 +113,14 @@ -
+
- {% if perms.exercises.change_exercise and exercise.owned_by == user %} - - Add item to exercise - + {% if perms.exercises.change_exercise %} + {% if exercise.owned_by == user or user in exercise.collaborators.all %} + + Add item to exercise + + {% endif %} {% endif %} @@ -245,6 +248,12 @@ var answerIsCorrect = answerAttempt == answerCorrect; break; + case 'imagematchreversed': + var answerAttempt = $(this).find("input:checked").val(); + var answerCorrect = '1'; + var answerIsCorrect = answerAttempt == answerCorrect; + break; + case 'fillintheblank': var answerAttempt = $(this).find('input').val(); var answerCorrect = $(this).find('input').attr('data-correct').split("*"); // Make list of answers @@ -282,7 +291,7 @@ // Score answers: most exercise formats - $('.exerciseformat-multiplechoice, .exerciseformat-fillintheblank-fitb-item, .exerciseformat-imagematch').on('change', function(){ + $('.exerciseformat-multiplechoice, .exerciseformat-fillintheblank-fitb-item, .exerciseformat-imagematch, .exerciseformat-imagematchreversed').on('change', function(){ answerClass = $(this).attr('class').split(" ")[0]; answerId = $(this).attr('id'); scoreAnswers(answerClass, answerId); @@ -332,6 +341,14 @@ 'result': ($(this).find('.exercise-result-correct').length ? true : false) }); }); + // Format: image match (reversed) + $('.exerciseformat-imagematchreversed').each(function(){ + attempt_detail.push({ + 'question': $(this).find('.exerciseformat-imagematchreversed-title').text().trim(), + 'answer': $(this).find('input:checked').next().find('img').attr('src'), + 'result': ($(this).find('.exercise-result-correct').length ? true : false) + }); + }); // Format: fill in the blank $('.exerciseformat-fillintheblank').each(function(){ // Get array of answers @@ -365,6 +382,8 @@ }); // Store the hidden value as a JSON string, ready to pass to back-end if (attempt_detail.length) $('#exercise-attempt-form-attemptdetail').val(JSON.stringify(attempt_detail)) + + console.log(attempt_detail) }); // Calculate how long it takes the user to attempt the exercise diff --git a/django/exercises/templates/exercises/exercise-edit.html b/django/exercises/templates/exercises/exercise-edit.html index 0feef13..b13ecb8 100644 --- a/django/exercises/templates/exercises/exercise-edit.html +++ b/django/exercises/templates/exercises/exercise-edit.html @@ -15,4 +15,22 @@

Edit Exercise

+ + + {% endblock %} \ No newline at end of file diff --git a/django/exercises/templates/exercises/exercise-list.html b/django/exercises/templates/exercises/exercise-list.html index 5567f9e..259cc3b 100644 --- a/django/exercises/templates/exercises/exercise-list.html +++ b/django/exercises/templates/exercises/exercise-list.html @@ -10,7 +10,7 @@ - {% include "exercises/snippets/list-head-select.html" with page='exercise' dataset=languages label=' Language' id='language' default_value=language_id %} + {% include "exercises/snippets/list-head-select.html" with page='exercise' dataset=languages label=' Language' id='language' %} {% include "exercises/snippets/list-head-select.html" with page='exercise' dataset=formats label=' Format' id='format' %} @@ -60,8 +60,8 @@ {% endif %} {% for exercise in exercise_list %} - - {% if user.is_superuser or exercise.owned_by == user or exercise.items_count %} + + {% if user.is_superuser or exercise.owned_by == user or user in exercise.collaborators or exercise.items_count %} {% if organise == 'exercise_format' %} {% ifchanged exercise.exercise_format %} diff --git a/django/exercises/templates/exercises/snippets/exercise-content-edit-link.html b/django/exercises/templates/exercises/snippets/exercise-content-edit-link.html index 190cf74..e59e238 100644 --- a/django/exercises/templates/exercises/snippets/exercise-content-edit-link.html +++ b/django/exercises/templates/exercises/snippets/exercise-content-edit-link.html @@ -1,5 +1,7 @@ -{% if perms.exercises.change_exercise and exercise.owned_by == user %} - - Edit - +{% if perms.exercises.change_exercise %} + {% if exercise.owned_by == user or user in exercise.collaborators.all %} + + Edit + + {% endif %} {% endif %} \ No newline at end of file diff --git a/django/exercises/templates/exercises/snippets/exercise-detail-formats/fill-in-the-blank.html b/django/exercises/templates/exercises/snippets/exercise-detail-formats/fill-in-the-blank.html index 45df91c..cbb040b 100644 --- a/django/exercises/templates/exercises/snippets/exercise-detail-formats/fill-in-the-blank.html +++ b/django/exercises/templates/exercises/snippets/exercise-detail-formats/fill-in-the-blank.html @@ -11,7 +11,7 @@ {{ forloop.counter }}. {% endif %} - {{ fitb.source_text_or_audiomsg }} + {{ fitb.source_text_or_audiomsg | safe | linebreaks }} {% include "exercises/snippets/audio.html" with audio=fitb.source_audio id=forloop.counter|add:100000 %} diff --git a/django/exercises/templates/exercises/snippets/exercise-detail-formats/image-match.html b/django/exercises/templates/exercises/snippets/exercise-detail-formats/image-match.html index adbcda9..2bb093b 100644 --- a/django/exercises/templates/exercises/snippets/exercise-detail-formats/image-match.html +++ b/django/exercises/templates/exercises/snippets/exercise-detail-formats/image-match.html @@ -1,40 +1,88 @@ -{% for im in exercise.exerciseformatimagematch_set.all %} +{# Standard image match layout #} +{% if not exercise.exercise_format_reverse_image_match %} + {% for im in exercise.exerciseformatimagematch_set.all %} -
+
-
Image {{ forloop.counter }}
+
Image {{ forloop.counter }}
- + + {% for label in exercise.image_match_label_options %} + {% comment %}Set value to 1 if a match or 0 if not a match{% endcomment %} + + {% endfor %} + + + + Match the image to the label + + + {% if not exercise.is_a_formal_assessment %} +
+ +
+
Answer: {{ im.label }}
+ {% if im.correct_answer_feedback or im.correct_answer_feedback_audio %} +
+ {{ im.correct_answer_feedback | safe }} + {% include "exercises/snippets/audio.html" with audio=im.correct_answer_feedback_audio id=forloop.counter|add:100000 %} +
+ {% endif %} +
+
+
+
+ {% endif %} + + {% include 'exercises/snippets/exercise-content-edit-link.html' with id=im.id %} + +
+ {% empty %} + {% include 'exercises/snippets/exercise-content-empty.html' %} + {% endfor %} + + + +{# Reversed image match layout #} +{% else %} + {% for im in exercise.exerciseformatimagematch_set.all %} + +
+ +
{{ forloop.counter }}. {{ im.label }}
+ + {% for image in exercise.image_match_image_options %} +
+ {% comment %}Set value to 1 if a match or 0 if not a match{% endcomment %} + + + + Match the image to the label + +
{% endfor %} - - - - Match the image to the label - - - {% if not exercise.is_a_formal_assessment %} -
- -
-
Answer: {{ im.label }}
- {% if im.correct_answer_feedback %} -
{{ im.correct_answer_feedback | safe }}
- {% endif %} + + {% if not exercise.is_a_formal_assessment %} +
+ +
+
Answer: image of answer
+ {% if im.correct_answer_feedback %} +
{{ im.correct_answer_feedback | safe }}
+ {% endif %} +
+
+
-
-
-
- {% endif %} + {% endif %} - {% include 'exercises/snippets/exercise-content-edit-link.html' with id=im.id %} + {% include 'exercises/snippets/exercise-content-edit-link.html' with id=im.id %} -
-{% empty %} - {% include 'exercises/snippets/exercise-content-empty.html' %} -{% endfor %} +
+ {% empty %} + {% include 'exercises/snippets/exercise-content-empty.html' %} + {% endfor %} +{% endif %} \ No newline at end of file diff --git a/django/exercises/templates/exercises/snippets/exercise-detail-formats/multiple-choice.html b/django/exercises/templates/exercises/snippets/exercise-detail-formats/multiple-choice.html index 5ad6c3a..414495c 100644 --- a/django/exercises/templates/exercises/snippets/exercise-detail-formats/multiple-choice.html +++ b/django/exercises/templates/exercises/snippets/exercise-detail-formats/multiple-choice.html @@ -7,7 +7,7 @@ {% include "exercises/snippets/force-dir-auto.html" with text=mc.question_text_or_audiomsg %}
- {{ forloop.counter }}. {{ mc.question_text_or_audiomsg }} + {{ forloop.counter }}. {{ mc.question_text_or_audiomsg | safe }} {% include "exercises/snippets/audio.html" with audio=mc.question_audio id=forloop.counter %}
@@ -20,7 +20,7 @@ {% include "exercises/snippets/audio.html" with audio=option.1 id=option.2 %} @@ -32,8 +32,11 @@
Answer:
{{ mc.answer_text | safe }}
- {% if mc.correct_answer_feedback %} -
{{ mc.correct_answer_feedback | safe }}
+ {% if mc.correct_answer_feedback or mc.correct_answer_feedback_audio %} +
+ {{ mc.correct_answer_feedback | safe }} + {% include "exercises/snippets/audio.html" with audio=mc.correct_answer_feedback_audio id=forloop.counter|add:100000 %} +
{% endif %}
diff --git a/django/exercises/templates/exercises/snippets/exercise-detail-formats/sentence-builder.html b/django/exercises/templates/exercises/snippets/exercise-detail-formats/sentence-builder.html index 0b84f07..d9e79d0 100644 --- a/django/exercises/templates/exercises/snippets/exercise-detail-formats/sentence-builder.html +++ b/django/exercises/templates/exercises/snippets/exercise-detail-formats/sentence-builder.html @@ -6,7 +6,7 @@
{% include "exercises/snippets/force-dir-auto.html" with text=sb.sentence_source_text_or_audiomsg %}
- {{ forloop.counter }}. {{ sb.sentence_source_text_or_audiomsg }} + {{ forloop.counter }}. {{ sb.sentence_source_text_or_audiomsg | safe }} {% include "exercises/snippets/audio.html" with audio=sb.sentence_source_audio id=forloop.counter|add:100000 %}
@@ -41,9 +41,12 @@
-
Answer: {{ sb.sentence_translated }}
- {% if sb.correct_answer_feedback %} -
{{ sb.correct_answer_feedback | safe }}
+
Answer: {{ sb.sentence_translated | safe }}
+ {% if sb.correct_answer_feedback or sb.correct_answer_feedback_audio %} +
+ {{ sb.correct_answer_feedback | safe }} + {% include "exercises/snippets/audio.html" with audio=sb.correct_answer_feedback_audio id=forloop.counter|add:100000 %} +
{% endif %}
diff --git a/django/exercises/templates/exercises/snippets/exercise-detail-formats/translation.html b/django/exercises/templates/exercises/snippets/exercise-detail-formats/translation.html index 187ddcc..4c11075 100644 --- a/django/exercises/templates/exercises/snippets/exercise-detail-formats/translation.html +++ b/django/exercises/templates/exercises/snippets/exercise-detail-formats/translation.html @@ -3,18 +3,18 @@
- {% if t.translation_source_image %} + {% if t.translation_source_image_path %} {% elif t.translation_source_text %}
- {{ t.translation_source_text }} + {{ t.translation_source_text | safe }}
{% elif user.is_staff %} @@ -42,8 +42,11 @@
Answer:
{{ t.correct_translation | linebreaks }}
- {% if t.correct_answer_feedback %} -
{{ t.correct_answer_feedback | safe }}
+ {% if t.correct_answer_feedback or t.correct_answer_feedback_audio %} +
+ {{ t.correct_answer_feedback | safe }} + {% include "exercises/snippets/audio.html" with audio=t.correct_answer_feedback_audio id=forloop.counter|add:100000 %} +
{% endif %}
diff --git a/django/exercises/views.py b/django/exercises/views.py index 2096d8f..55a6eb4 100644 --- a/django/exercises/views.py +++ b/django/exercises/views.py @@ -4,6 +4,8 @@ from django.core.exceptions import PermissionDenied from django.contrib.auth.decorators import login_required from django.http import HttpResponse, Http404 +from functools import reduce +from operator import (or_, and_) from django.shortcuts import redirect from django.urls import reverse_lazy from account.models import User @@ -106,7 +108,7 @@ class ExerciseCreateView(PermissionRequiredMixin, CreateView): template_name = 'exercises/exercise-add.html' model = models.Exercise - fields = ['name', 'language', 'exercise_format', 'theme', 'difficulty', 'instructions', 'instructions_image', 'instructions_image_width_percent', 'is_a_formal_assessment', 'is_published'] + fields = ['name', 'language', 'exercise_format', 'exercise_format_reverse_image_match', 'theme', 'difficulty', 'font_size', 'instructions', 'instructions_image', 'instructions_image_url', 'instructions_image_width_percent', 'is_a_formal_assessment', 'is_published'] permission_required = ('exercises.add_exercise') success_url = reverse_lazy('exercises:list') @@ -132,7 +134,7 @@ class ExerciseUpdateView(PermissionRequiredMixin, UpdateView): template_name = 'exercises/exercise-edit.html' model = models.Exercise - fields = ['name', 'language', 'theme', 'difficulty', 'instructions', 'instructions_image', 'instructions_image_width_percent', 'is_a_formal_assessment', 'owned_by', 'is_published'] + fields = ['name', 'language', 'exercise_format_reverse_image_match', 'theme', 'difficulty', 'font_size', 'instructions', 'instructions_image', 'instructions_image_url', 'instructions_image_width_percent', 'is_a_formal_assessment', 'owned_by', 'collaborators', 'is_published'] permission_required = ('exercises.change_exercise') def get_queryset(self): @@ -140,7 +142,7 @@ def get_queryset(self): Only show this page if the current user is an admin or a teacher who owns the exercise """ q = super().get_queryset() - return q if self.request.user.is_superuser else q.filter(owned_by=self.request.user) + return q if self.request.user.is_superuser else q.filter(Q(owned_by=self.request.user | Q(collaborators__in=[self.request.user]))) def get_success_url(self, **kwargs): """ @@ -208,7 +210,7 @@ class ExerciseDetailView(LoginRequiredMixin, DetailView): model = models.Exercise def get_queryset(self): - return self.model.objects.filter(Q(is_published=True) | Q(owned_by=self.request.user)) + return self.model.objects.filter(Q(is_published=True) | Q(owned_by=self.request.user) | Q(collaborators__in=[self.request.user])).distinct() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -237,7 +239,32 @@ def get_queryset(self): """ # 1. Enforcing privacy rules - queryset = self.model.objects.filter(Q(is_published=True) | Q(owned_by=self.request.user)) + + # Only show published, unless user is owner or collaborator on exercise + queryset = self.model.objects.filter( + Q(is_published=True) + | + Q(owned_by=self.request.user) + | + Q(collaborators__in=[self.request.user]) + ).distinct() + + # Students can only see exercises if they're in that class + if self.request.user.role.name == 'student': + # Build list of filters: (language and difficulty) + filters = [] + for school_class in self.request.user.classes.all(): + filters.append( + reduce( + and_, + ( + Q(language=school_class.language), + Q(difficulty=school_class.difficulty) + ) + ) + ) + # Apply filters to queryset (uses or_, as can be any) + queryset = queryset.filter(reduce(or_, filters)) # 2. Searching search = self.request.GET.get('search', '').strip() @@ -254,13 +281,8 @@ def get_queryset(self): # 3. Filtering # language language = self.request.GET.get('language', '') - # if language is not specified and user is logged in, show default language - if language == '' and self.request.user.is_authenticated: - DEFAULT_LANGUAGE = User.objects.get(pk=self.request.user.id).default_language - if DEFAULT_LANGUAGE is not None: - queryset = queryset.filter(language=DEFAULT_LANGUAGE.id) - # else if language is specified, filter on that language - elif language not in ['*', '']: + # if language is specified, filter on that language + if language not in ['*', '']: queryset = queryset.filter(language=language) # format format = self.request.GET.get('format', '*') @@ -294,15 +316,15 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # Language - if self.request.user.is_authenticated: - DEFAULT_LANGUAGE = User.objects.get(pk=self.request.user.id).default_language - if DEFAULT_LANGUAGE is not None and self.request.GET.get('language') != '*': - context['language_id'] = DEFAULT_LANGUAGE.id # Organise context['organise'] = self.request.GET.get('organise', 'name') # Add data for related models, e.g. for populating select lists, etc. - context['languages'] = models.Language.objects.filter(is_published=True) + if self.request.user.role.name == 'student': + school_classes = models.SchoolClass.objects.filter(user__id=self.request.user.id) + languages = models.Language.objects.filter(is_published=True, schoolclass__in=school_classes) + else: + languages = models.Language.objects.filter(is_published=True) + context['languages'] = languages context['themes'] = models.Theme.objects.filter(is_published=True) context['formats'] = models.ExerciseFormat.objects.filter(is_published=True) context['difficulties'] = models.Difficulty.objects.all() @@ -331,7 +353,7 @@ def get_form_class(self, **kwargs): except self.model.DoesNotExist: raise Http404() # Only show if current user is admin or teacher who owns parent exercise - if self.request.user.is_superuser or exercise.owned_by == self.request.user: + if self.request.user.is_superuser or exercise.owned_by == self.request.user or self.request.user in exercise.collaborators: # Set the correct form if exercise.exercise_format.name == 'Multiple Choice': return forms.ExerciseFormatMultipleChoiceForm @@ -353,7 +375,7 @@ def get_queryset(self): Only show this page if the current user is an admin or a teacher who owns the exercise """ q = super().get_queryset() - return q if self.request.user.is_superuser else q.filter(exercise__owned_by=self.request.user) + return q if self.request.user.is_superuser else q.filter(Q(owned_by=self.request.user | Q(collaborators__in=[self.request.user]))) def form_valid(self, form, **kwargs): """