diff --git a/assets/js/submissions.js b/assets/js/submissions.js new file mode 100644 index 000000000..9da86fae4 --- /dev/null +++ b/assets/js/submissions.js @@ -0,0 +1,56 @@ +function submissions_list() { + var checkedButtons = []; // Array to store data-slug of checked buttons + + $('button').on('click', function(event) { + event.preventDefault(); + var icon = $(this).find('.glyphicon'); + + if (icon.hasClass('glyphicon-unchecked')) { + icon.removeClass('glyphicon-unchecked').addClass('glyphicon-check'); + $(this).attr("data-status", "checked"); // set data-status to 'checked' + checkedButtons.push($(this).attr("data-tagslug")); // add data-slug of the button to the array + } else { + icon.removeClass('glyphicon-check').addClass('glyphicon-unchecked'); + $(this).attr("data-status", "unchecked"); // set data-status to 'unchecked' + var index = checkedButtons.indexOf($(this).attr("data-tagslug")); // get the index of the button in the array + if (index !== -1) { + checkedButtons.splice(index, 1); // remove it from the array if it exists + } + } + + refresh_filters(); + }); + + var filter_items = function (submissions) { + const filterTags = $.makeArray($('.filter-submissions button.filter-tag:has(.glyphicon-check)')) + .map(function (elem) { + return $(elem).attr('data-tagslug'); + }); + const filterStatuses = $.makeArray($('.filter-submissions button.filter-status:has(.glyphicon-check)')) + .map(function (elem) { + return $(elem).attr('data-status'); + }); + return submissions.map(function (submission) { + // Set intercetion tags ∩ filters + const intersectTags = submission.tag_slugs.filter(function (tag) { + return filterTags.indexOf(tag) >= 0; + }); + return intersectTags.length === filterTags.length + }); + }; + + function refresh_filters() { + $('.filter-submissions table tr').each(function(){ + $(this).hide(); // hide all rows by default + + $(this).find('tagging.tag').each(function(){ + if (checkedButtons.includes($(this).attr("data-tagslug"))) { + $(this).closest('tr').show(); // show the row if it has a checked button + return false; // exit the inner loop + } + }); + }); + } + + refresh_filters(); +} diff --git a/course/migrations/0059_submissiontag.py b/course/migrations/0059_submissiontag.py new file mode 100644 index 000000000..aa1beed47 --- /dev/null +++ b/course/migrations/0059_submissiontag.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.11 on 2024-04-24 11:06 + +import colorfield +from django.db import migrations, models +import django.db.models.deletion +import lib.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("course", "0058_coursemodule_model_answer_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SubmissionTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(help_text="Display name for tag", max_length=20), + ), + ( + "slug", + models.SlugField( + help_text="Slug key for tag. If left blank, one is created from name", + max_length=20, + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Describe the usage or meaning of this tag", + max_length=155, + ), + ), + ( + "color", + colorfield.ColorField( + default="#CD0000", + help_text="Color that is used as background for this tag", + max_length=7, + ), + ), + ( + "visible_to_students", + models.BooleanField( + default=False, verbose_name="LABEL_VISIBLE_TO_STUDENTS" + ), + ), + ( + "filter_status", + models.BooleanField( + default=False, verbose_name="LABEL_FILTER_STATUS" + ), + ), + ( + "course_instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="submissiontags", + to="course.courseinstance", + verbose_name="LABEL_COURSE_INSTANCE", + ), + ), + ], + options={ + "verbose_name": "MODEL_NAME_SUBMISSION_TAG", + "verbose_name_plural": "MODEL_NAME_SUBMISSION_TAG_PLURAL", + "ordering": ["course_instance", "name"], + }, + bases=(lib.models.UrlMixin, models.Model), + ), + ] diff --git a/course/migrations/0060_remove_submissiontag_filter_status.py b/course/migrations/0060_remove_submissiontag_filter_status.py new file mode 100644 index 000000000..b6d07e958 --- /dev/null +++ b/course/migrations/0060_remove_submissiontag_filter_status.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.11 on 2024-04-24 11:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("course", "0059_submissiontag"), + ] + + operations = [ + migrations.RemoveField( + model_name="submissiontag", + name="filter_status", + ), + ] diff --git a/course/models.py b/course/models.py index 3be2edb8f..ef54d3df3 100644 --- a/course/models.py +++ b/course/models.py @@ -51,7 +51,6 @@ from exercise.exercise_models import LearningObject from threshold.models import CourseModuleRequirement - logger = logging.getLogger('aplus.course') # Read pseudonymization data from file @@ -347,6 +346,37 @@ def is_valid_slug(self, slug_candidate): # pylint: disable=arguments-renamed return not qs.exists() +class SubmissionTag(UrlMixin, ColorTag): + course_instance = models.ForeignKey('CourseInstance', + verbose_name=_('LABEL_COURSE_INSTANCE'), + on_delete=models.CASCADE, + related_name="submissiontags", + ) + + visible_to_students = models.BooleanField( + verbose_name=_('LABEL_VISIBLE_TO_STUDENTS'), + default=False, + ) + + class Meta: + verbose_name = _('MODEL_NAME_SUBMISSION_TAG') + verbose_name_plural = _('MODEL_NAME_SUBMISSION_TAG_PLURAL') + ordering = ['course_instance', 'name'] + + def get_url_kwargs(self): + return dict(tag_id=self.id, **self.course_instance.get_url_kwargs()) # pylint: disable=use-dict-literal + + def is_valid_slug(self, slug_candidate): # pylint: disable=arguments-renamed + assert self.course_instance + if not slug_candidate: + return False + qs = self.__class__.objects.filter( + course_instance=self.course_instance, slug=slug_candidate) + if self.pk is not None: + qs = qs.exclude(pk=self.pk) + return not qs.exists() + + class HardcodedUserTag(UserTag): class Meta: verbose_name = _('MODEL_NAME_HARDCODED_USER_TAG') diff --git a/edit_course/course_forms.py b/edit_course/course_forms.py index a92b13818..ce5a1f2f3 100644 --- a/edit_course/course_forms.py +++ b/edit_course/course_forms.py @@ -12,9 +12,10 @@ from django_colortag.forms import ColorTagForm from aplus.api import api_reverse -from course.models import LearningObjectCategory, CourseModule, CourseInstance, UserTag +from course.models import LearningObjectCategory, CourseModule, CourseInstance, UserTag, SubmissionTag from course.sis import get_sis_configuration, StudentInfoSystem from exercise.models import CourseChapter +from exercise.submission_models import SubmissionTagging from lib.validators import generate_url_key_validator from lib.fields import UsersSearchSelectField from lib.widgets import DateTimeLocalInput @@ -267,6 +268,13 @@ class CloneInstanceForm(forms.Form): required=False, initial=True, ) + submissiontags = forms.BooleanField( + label=_('LABEL_SUBMISSION_TAGS'), + help_text=_('LABEL_SUBMISSION_TAGS_HELPTEXT'), + required=False, + initial=True, + ) + if settings.GITMANAGER_URL: key_year = forms.IntegerField( label=_('LABEL_YEAR'), @@ -400,6 +408,30 @@ def get_base_object(self, course_instance): return obj +class SubmissionTagForm(ColorTagForm): + + class Meta(ColorTagForm.Meta): + model = SubmissionTag + fields = [ + 'name', + 'slug', + 'description', + 'color', + ] + labels = { + 'name': _('LABEL_NAME'), + 'slug': _('LABEL_SLUG'), + 'description': _('LABEL_DESCRIPTION'), + 'color': _('LABEL_COLOR'), + } + + @classmethod + def get_base_object(self, course_instance): + obj = self.Meta.model() + obj.course_instance = course_instance + return obj + + class SelectUsersForm(forms.Form): user = UsersSearchSelectField(queryset=UserProfile.objects.none(), initial_queryset=UserProfile.objects.none()) @@ -412,6 +444,19 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.fields['user'].queryset = course_instance.get_student_profiles() +class SubmissionTaggingForm(forms.ModelForm): + class Meta(ColorTagForm.Meta): + model = SubmissionTagging + fields = [ + 'tag', + 'submission', + ] + labels = { + 'tag': _('LABEL_SUBMISSION_TAG'), + 'submission': _('LABEL_SUBMISSION'), + } + + class GitmanagerForm(forms.Form): key = forms.SlugField( label=_('LABEL_KEY'), diff --git a/edit_course/templates/edit_course/edit_course_base.html b/edit_course/templates/edit_course/edit_course_base.html index a52187088..f075dee7e 100644 --- a/edit_course/templates/edit_course/edit_course_base.html +++ b/edit_course/templates/edit_course/edit_course_base.html @@ -53,6 +53,11 @@ {% translate "TAGS" %} + +
+ + + {% translate "ADD_NEW_SUBMISSION_TAG" %} + +
+{% translate "TAG" %} | +{% translate "SLUG" %} | +{% translate "DESCRIPTION" %} | +{% translate "ACTIONS" %} | +
---|---|---|---|
{{ tag|colortag }} | +{{ tag.slug }} | +{{ tag.description }} | ++ + + {% translate "EDIT" %} + + + + {% translate "REMOVE" %} + + | +
{% translate "NO_SUBMISSION_TAGS" %} | +
{% translate "SUBMITTERS" %} | {% translate "TIME" %} | {% translate "STATUS" %} | {% translate "GRADE" %} | +{% translate "TAGS" %} | |
---|---|---|---|---|---|
{% profiles submission.submitters.all instance is_teacher %} | @@ -96,6 +108,11 @@{% format_points submission.grade feedback_revealed False %} | ++ {% for tagging in submission.submission_taggings.all %} + {{ tagging.tag|colortag }} + {% endfor %} + | {% if submission.grader %} @@ -121,5 +138,43 @@ |