diff --git a/AKSubmission/admin.py b/AKSubmission/admin.py deleted file mode 100644 index 846f6b4061a68eda58bc9c76c36603d1e7721ee8..0000000000000000000000000000000000000000 --- a/AKSubmission/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/AKSubmission/api.py b/AKSubmission/api.py index db46d40c0455c0553bb311302cc39145c41af1f7..4bcbafa8eb85d80118e4f0ff8b2d8e382c04feb8 100644 --- a/AKSubmission/api.py +++ b/AKSubmission/api.py @@ -25,9 +25,13 @@ def ak_interest_indication_active(event, current_timestamp): def increment_interest_counter(request, event_slug, pk, **kwargs): """ Increment interest counter for AK + + This view either returns a HTTP 200 if the counter was incremented, + an HTTP 403 if indicating interest is currently not allowed, + or an HTTP 404 if there is no matching AK for the given primary key and event slug. """ try: - ak = AK.objects.get(pk=pk) + ak = AK.objects.get(pk=pk, event__slug=event_slug) # Check whether interest indication is currently allowed current_timestamp = datetime.now().astimezone(ak.event.timezone) if ak_interest_indication_active(ak.event, current_timestamp): diff --git a/AKSubmission/apps.py b/AKSubmission/apps.py index 6a3d8e74a76d551e512471836bf7c673d047a970..51527b4e8d2eb0f9b88bc4f42ffdf114d6f4fcaf 100644 --- a/AKSubmission/apps.py +++ b/AKSubmission/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class AksubmissionConfig(AppConfig): + """ + App configuration (default, only specifies name of the app) + """ name = 'AKSubmission' diff --git a/AKSubmission/forms.py b/AKSubmission/forms.py index 96e0419df12c54be457544d5448a8eea702c8e68..1ee786d22963dacf1301d1b2984b7771defc1d58 100644 --- a/AKSubmission/forms.py +++ b/AKSubmission/forms.py @@ -1,3 +1,7 @@ +""" +Submission-specific forms +""" + import itertools import re @@ -7,10 +11,21 @@ from django.utils.translation import gettext_lazy as _ from AKModel.availability.forms import AvailabilitiesFormMixin from AKModel.availability.models import Availability -from AKModel.models import AK, AKOwner, AKCategory, AKRequirement, AKSlot, AKOrgaMessage, Event +from AKModel.models import AK, AKOwner, AKCategory, AKRequirement, AKSlot, AKOrgaMessage class AKForm(AvailabilitiesFormMixin, forms.ModelForm): + """ + Base form to add and edit AKs + + Contains suitable widgets for the different data types, restricts querysets (e.g., of requirements) to entries + belonging to the event this AK belongs to. + Prepares initial slot creation (by accepting multiple input formats and a list of slots to generate), + automatically generate short names and wiki links if necessary + + Will be modified/used by :class:`AKSubmissionForm` (that allows to add slots and excludes links) + and :class:`AKWishForm` + """ required_css_class = 'required' split_string = re.compile('[,;]') @@ -57,7 +72,14 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm): @staticmethod def _clean_duration(duration): - # Handle different duration formats (h:mm and decimal comma instead of point) + """ + Clean/convert input format for the duration(s) of the slot(s) + + Handle different duration formats (h:mm and decimal comma instead of point) + + :param duration: raw input, either with ":", "," or "." + :return: normalized duration (point-separated hour float) + """ if ":" in duration: h, m = duration.split(":") duration = int(h) + int(m) / 60 @@ -66,31 +88,44 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm): try: float(duration) - except ValueError: + except ValueError as exc: raise ValidationError( _('"%(duration)s" is not a valid duration'), code='invalid', params={'duration': duration}, - ) + ) from exc return duration def clean(self): + """ + Normalize/clean inputs + + Generate a (not yet used) short name if field was left blank, generate a wiki link, + create a list of normalized slot durations + + :return: cleaned inputs + """ cleaned_data = super().clean() # Generate short name if not given short_name = self.cleaned_data["short_name"] if len(short_name) == 0: short_name = self.cleaned_data['name'] + # First try to split AK name at positions with semantic value (e.g., where the full name is separated + # by a ':'), if not possible, do a hard cut at the maximum specified length short_name = short_name.partition(':')[0] short_name = short_name.partition(' - ')[0] short_name = short_name.partition(' (')[0] short_name = short_name[:AK._meta.get_field('short_name').max_length] + # Check whether this short name already exists... for i in itertools.count(1): + # ...and either use it... if not AK.objects.filter(short_name=short_name, event=self.cleaned_data["event"]).exists(): break + # ... or postfix a number starting at 1 and growing until an unused short name is found digits = len(str(i)) - short_name = '{}-{}'.format(short_name[:-(digits + 1)], i) + short_name = f'{short_name[:-(digits + 1)]}-{i}' cleaned_data["short_name"] = short_name # Generate wiki link @@ -106,35 +141,57 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm): class AKSubmissionForm(AKForm): + """ + Form for Submitting new AKs + + Is a special variant of :class:`AKForm` that does not allow to manually edit wiki and protocol links and enforces + the generation of at least one slot. + """ class Meta(AKForm.Meta): - exclude = ['link', 'protocol_link'] + # Exclude fields again that were previously included in the parent class + exclude = ['link', 'protocol_link'] #pylint: disable=modelform-uses-exclude def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Add field for durations + # Add field for durations (cleaning will be handled by parent class) self.fields["durations"] = forms.CharField( widget=forms.Textarea, label=_("Duration(s)"), help_text=_( - "Enter at least one planned duration (in hours). If your AK should have multiple slots, use multiple lines"), - initial= - self.initial.get('event').default_slot + "Enter at least one planned duration (in hours). " + "If your AK should have multiple slots, use multiple lines"), + initial=self.initial.get('event').default_slot ) def clean_availabilities(self): + """ + Automatically improve availabilities entered. + If the user did not specify availabilities assume the full event duration is possible + :return: cleaned availabilities + (either user input or one availability for the full length of the event if user input was empty) + """ availabilities = super().clean_availabilities() - # If the user did not specify availabilities assume the full event duration is possible if len(availabilities) == 0: availabilities.append(Availability.with_event_length(event=self.cleaned_data["event"])) return availabilities class AKWishForm(AKForm): + """ + Form for submitting or editing wishes + + Is a special variant of :class:`AKForm` that does not allow to specify owner(s) or + manually edit wiki and protocol links + """ class Meta(AKForm.Meta): - exclude = ['owners', 'link', 'protocol_link'] + # Exclude fields again that were previously included in the parent class + exclude = ['owners', 'link', 'protocol_link'] #pylint: disable=modelform-uses-exclude class AKOwnerForm(forms.ModelForm): + """ + Form to create/edit AK owners + """ required_css_class = 'required' class Meta: @@ -146,6 +203,9 @@ class AKOwnerForm(forms.ModelForm): class AKDurationForm(forms.ModelForm): + """ + Form to add an additional slot to a given AK + """ class Meta: model = AKSlot fields = ['duration', 'ak', 'event'] @@ -156,6 +216,9 @@ class AKDurationForm(forms.ModelForm): class AKOrgaMessageForm(forms.ModelForm): + """ + Form to create a confidential message to the organizers belonging to a given AK + """ class Meta: model = AKOrgaMessage fields = ['ak', 'text', 'event'] diff --git a/AKSubmission/models.py b/AKSubmission/models.py index ef1bff7a11e17e483fed79316c0cc03ed143a982..a3a5f022ed22b77970dd300543c010dc310fb136 100644 --- a/AKSubmission/models.py +++ b/AKSubmission/models.py @@ -3,14 +3,15 @@ from django.conf import settings from django.core.mail import EmailMessage from django.db.models.signals import post_save from django.dispatch import receiver -from django.urls import reverse_lazy from AKModel.models import AKOrgaMessage, AKSlot @receiver(post_save, sender=AKOrgaMessage) -def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwargs): - # React to newly created Orga message by sending an email +def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwargs): # pylint: disable=unused-argument + """ + React to newly created Orga message by sending an email + """ if created and settings.SEND_MAILS: host = 'https://' + settings.ALLOWED_HOSTS[0] if len(settings.ALLOWED_HOSTS) > 0 else 'http://127.0.0.1:8000' @@ -26,10 +27,12 @@ def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwarg @receiver(post_save, sender=AKSlot) -def slot_created_handler(sender, instance: AKSlot, created, **kwargs): - # React to slots that are created after the plan was already published by sending an email - - if created and settings.SEND_MAILS and apps.is_installed("AKPlan") and not instance.event.plan_hidden and instance.room is None and instance.start is None: +def slot_created_handler(sender, instance: AKSlot, created, **kwargs): # pylint: disable=unused-argument + """ + React to slots that are created after the plan was already published by sending an email + """ + if created and settings.SEND_MAILS and apps.is_installed("AKPlan") \ + and not instance.event.plan_hidden and instance.room is None and instance.start is None: # pylint: disable=too-many-boolean-expressions,line-too-long host = 'https://' + settings.ALLOWED_HOSTS[0] if len(settings.ALLOWED_HOSTS) > 0 else 'http://127.0.0.1:8000' url = f"{host}{instance.ak.detail_url}" diff --git a/AKSubmission/templatetags/tags_AKSubmission.py b/AKSubmission/templatetags/tags_AKSubmission.py index c1d42989a0c1ab931c2e3ee6746667297451abea..b68fe48a5ec3c929113162c223d1b08d8a049bc7 100644 --- a/AKSubmission/templatetags/tags_AKSubmission.py +++ b/AKSubmission/templatetags/tags_AKSubmission.py @@ -6,6 +6,11 @@ register = template.Library() @register.filter def bool_symbol(bool_val): + """ + Show a nice icon instead of the string true/false + :param bool_val: boolean value to iconify + :return: check or times icon depending on the value + """ if bool_val: return fa6_icon("check", "fas") return fa6_icon("times", "fas") @@ -13,14 +18,34 @@ def bool_symbol(bool_val): @register.inclusion_tag("AKSubmission/tracks_list.html") def track_list(tracks, event_slug): + """ + Generate a clickable list of tracks (one badge per track) based upon the tracks_list template + + :param tracks: tracks to consider + :param event_slug: slug of this event, required for link creation + :return: html fragment containing track links + """ return {"tracks": tracks, "event_slug": event_slug} @register.inclusion_tag("AKSubmission/category_list.html") def category_list(categories, event_slug): + """ + Generate a clickable list of categories (one badge per category) based upon the category_list template + + :param categories: categories to consider + :param event_slug: slug of this event, required for link creation + :return: html fragment containing category links + """ return {"categories": categories, "event_slug": event_slug} @register.inclusion_tag("AKSubmission/category_linked_badge.html") def category_linked_badge(category, event_slug): + """ + Generate a clickable category badge based upon the category_linked_badge template + :param category: category to show/link + :param event_slug: slug of this event, required for link creation + :return: html fragment containing badge + """ return {"category": category, "event_slug": event_slug} diff --git a/AKSubmission/tests.py b/AKSubmission/tests.py index 54ff20a06dda9701362aa5d8017e8313cbf689b0..018289aae6f73b36e2f1f6a11b11c016ee357748 100644 --- a/AKSubmission/tests.py +++ b/AKSubmission/tests.py @@ -9,6 +9,15 @@ from AKModel.tests import BasicViewTests class ModelViewTests(BasicViewTests, TestCase): + """ + Testcases for AKSubmission app. + + This extends :class:`BasicViewTests` for standard view and edit testcases + that are specified in this class as VIEWS and EDIT_TESTCASES. + + Additionally several additional testcases, in particular to test the API + and the dispatching for owner selection and editing are specified. + """ fixtures = ['model.json'] VIEWS = [ @@ -47,24 +56,27 @@ class ModelViewTests(BasicViewTests, TestCase): """ self.client.logout() - view_name_with_prefix, url = self._name_and_url(('akslot_edit', {'event_slug': 'kif42', 'pk': 1})) + _, url = self._name_and_url(('akslot_edit', {'event_slug': 'kif42', 'pk': 1})) response = self.client.get(url) self.assertEqual(response.status_code, 302, msg=f"AK Slot editing ({url}) possible even though slot was already scheduled") self._assert_message(response, "You cannot edit a slot that has already been scheduled") - view_name_with_prefix, url = self._name_and_url(('akslot_delete', {'event_slug': 'kif42', 'pk': 1})) + _, url = self._name_and_url(('akslot_delete', {'event_slug': 'kif42', 'pk': 1})) response = self.client.get(url) self.assertEqual(response.status_code, 302, msg=f"AK Slot deletion ({url}) possible even though slot was already scheduled") self._assert_message(response, "You cannot delete a slot that has already been scheduled") def test_slot_creation_deletion(self): + """ + Test creation and deletion of slots in frontend + """ ak_args = {'event_slug': 'kif42', 'pk': 1} redirect_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs=ak_args) + # Create a valid slot -> Redirect to AK detail page, message to user count_slots = AK.objects.get(pk=1).akslot_set.count() - create_url = reverse_lazy(f"{self.APP_NAME}:akslot_add", kwargs=ak_args) response = self.client.post(create_url, {'ak': 1, 'event': 2, 'duration': 1.5}) self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200, @@ -75,6 +87,8 @@ class ModelViewTests(BasicViewTests, TestCase): # Get primary key of newly created Slot slot_pk = AK.objects.get(pk=1).akslot_set.order_by('pk').last().pk + # Edit the recently created slot: Make sure view is accessible, post change + # -> redirect to detail page, duration updated edit_url = reverse_lazy(f"{self.APP_NAME}:akslot_edit", kwargs={'event_slug': 'kif42', 'pk': slot_pk}) response = self.client.get(edit_url) self.assertEqual(response.status_code, 200, msg=f"Cant open edit view for newly created slot ({edit_url})") @@ -84,6 +98,8 @@ class ModelViewTests(BasicViewTests, TestCase): self.assertEqual(AKSlot.objects.get(pk=slot_pk).duration, 2, msg="Slot was not correctly changed") + # Delete recently created slot: Make sure view is accessible, post deletion + # -> redirect to detail page, slot deleted, message to user deletion_url = reverse_lazy(f"{self.APP_NAME}:akslot_delete", kwargs={'event_slug': 'kif42', 'pk': slot_pk}) response = self.client.get(deletion_url) self.assertEqual(response.status_code, 200, @@ -95,55 +111,77 @@ class ModelViewTests(BasicViewTests, TestCase): self.assertEqual(AK.objects.get(pk=1).akslot_set.count(), count_slots, msg="AK still has to many slots") def test_ak_owner_editing(self): - # Test editing of new user + """ + Test dispatch of user editing requests + """ edit_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit_dispatch", kwargs={'event_slug': 'kif42'}) base_url = reverse_lazy(f"{self.APP_NAME}:submission_overview", kwargs={'event_slug': 'kif42'}) + + # Empty form/no user selected -> start page response = self.client.post(edit_url, {'owner_id': -1}) self.assertRedirects(response, base_url, status_code=302, target_status_code=200, msg_prefix="Did not redirect to start page even though no user was selected") self._assert_message(response, "No user selected") + # Correct selection -> user edit page for that user edit_redirect_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit", kwargs={'event_slug': 'kif42', 'slug': 'a'}) response = self.client.post(edit_url, {'owner_id': 1}) self.assertRedirects(response, edit_redirect_url, status_code=302, target_status_code=200, msg_prefix=f"Dispatch redirect failed (should go to {edit_redirect_url})") def test_ak_owner_selection(self): + """ + Test dispatch of owner selection requests + """ select_url = reverse_lazy(f"{self.APP_NAME}:akowner_select", kwargs={'event_slug': 'kif42'}) - create_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) + + # Empty user selection -> create a new user view response = self.client.post(select_url, {'owner_id': -1}) self.assertRedirects(response, create_url, status_code=302, target_status_code=200, msg_prefix="Did not redirect to user create view even though no user was specified") + # Valid user selected -> redirect to view that allows to add a new AK with this user as owner add_redirect_url = reverse_lazy(f"{self.APP_NAME}:submit_ak", kwargs={'event_slug': 'kif42', 'owner_slug': 'a'}) response = self.client.post(select_url, {'owner_id': 1}) self.assertRedirects(response, add_redirect_url, status_code=302, target_status_code=200, msg_prefix=f"Dispatch redirect to ak submission page failed (should go to {add_redirect_url})") def test_orga_message_submission(self): + """ + Test submission and storing of direct confident messages to organizers + """ form_url = reverse_lazy(f"{self.APP_NAME}:akmessage_add", kwargs={'event_slug': 'kif42', 'pk': 1}) detail_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs={'event_slug': 'kif42', 'pk': 1}) count_messages = AK.objects.get(pk=1).akorgamessage_set.count() + # Test that submission view is accessible response = self.client.get(form_url) self.assertEqual(response.status_code, 200, msg="Could not load message form view") + + # Test submission itself and the following redirect -> AK detail page response = self.client.post(form_url, {'ak': 1, 'event': 2, 'text': 'Test message text'}) self.assertRedirects(response, detail_url, status_code=302, target_status_code=200, msg_prefix=f"Did not trigger redirect to ak detail page ({detail_url})") + + # Make sure message was correctly saved in database and user is notified about that self._assert_message(response, "Message to organizers successfully saved") self.assertEqual(AK.objects.get(pk=1).akorgamessage_set.count(), count_messages + 1, msg="Message was not correctly saved") def test_interest_api(self): + """ + Test interest indicating API (access, functionality) + """ interest_api_url = "/kif42/api/ak/1/indicate-interest/" ak = AK.objects.get(pk=1) event = Event.objects.get(slug='kif42') ak_interest_counter = ak.interest_counter + # Check Access method (only POST) response = self.client.get(interest_api_url) self.assertEqual(response.status_code, 405, "Should not be accessible via GET") @@ -151,6 +189,7 @@ class ModelViewTests(BasicViewTests, TestCase): event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=+10) event.save() + # Test correct indication -> HTTP 200, counter increased response = self.client.post(interest_api_url) self.assertEqual(response.status_code, 200, f"API end point not working ({interest_api_url})") self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1, "Counter was not increased") @@ -158,30 +197,41 @@ class ModelViewTests(BasicViewTests, TestCase): event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=-2) event.save() + # Test indication outside of indication window -> HTTP 403, counter not increased response = self.client.post(interest_api_url) self.assertEqual(response.status_code, 403, "API end point still reachable even though interest indication window ended ({interest_api_url})") self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1, "Counter was increased even though interest indication window ended") + # Test call for non-existing AK -> HTTP 403 invalid_interest_api_url = "/kif42/api/ak/-1/indicate-interest/" response = self.client.post(invalid_interest_api_url) self.assertEqual(response.status_code, 404, f"Invalid URL reachable ({interest_api_url})") def test_adding_of_unknown_user(self): + """ + Test adding of a previously not existing owner to an AK + """ + # Pre-Check: AK detail page existing? detail_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs={'event_slug': 'kif42', 'pk': 1}) response = self.client.get(detail_url) self.assertEqual(response.status_code, 200, msg="Could not load ak detail view") + # Make sure AK detail page contains a link to add a new owner edit_url = reverse_lazy(f"{self.APP_NAME}:ak_edit", kwargs={'event_slug': 'kif42', 'pk': 1}) response = self.client.get(edit_url) self.assertEqual(response.status_code, 200, msg="Could not load ak detail view") self.assertContains(response, "Add person not in the list yet", msg_prefix="Link to add unknown user not contained") + # Check adding of a new owner by posting an according request + # -> Redirect to AK detail page, message to user, owners list updated self.assertEqual(AK.objects.get(pk=1).owners.count(), 1) - add_new_user_to_ak_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) + f"?add_to_existing_ak=1" - response = self.client.post(add_new_user_to_ak_url, {'name': 'New test owner', 'event': Event.get_by_slug('kif42').pk}) + add_new_user_to_ak_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) \ + + "?add_to_existing_ak=1" + response = self.client.post(add_new_user_to_ak_url, + {'name': 'New test owner', 'event': Event.get_by_slug('kif42').pk}) self.assertRedirects(response, detail_url, msg_prefix=f"No correct redirect: {add_new_user_to_ak_url} (POST) -> {detail_url}") self._assert_message(response, "Added 'New test owner' as new owner of 'Test AK Inhalt'") diff --git a/AKSubmission/views.py b/AKSubmission/views.py index 3c7fd8e93add9e1a34307ebd7faf594f91ce9eba..e7940a01c0d97a9fba0118265efe3fc9cdd9f84e 100644 --- a/AKSubmission/views.py +++ b/AKSubmission/views.py @@ -1,5 +1,6 @@ from datetime import timedelta from math import floor +from abc import ABC, abstractmethod from django.apps import apps from django.conf import settings @@ -23,27 +24,74 @@ from AKSubmission.forms import AKWishForm, AKOwnerForm, AKSubmissionForm, AKDura class SubmissionErrorNotConfiguredView(EventSlugMixin, TemplateView): + """ + View to show when submission is not correctly configured yet for this event + and hence the submission component cannot be used already. + """ template_name = "AKSubmission/submission_not_configured.html" class AKOverviewView(FilterByEventSlugMixin, ListView): + """ + View: Show a tabbed list of AKs belonging to this event split by categories + + Wishes show up in between of the other AKs in the category they belong to. + In contrast to :class:`SubmissionOverviewView` that inherits from this view, + on this view there is no form to add new AKs or edit owners. + + Since the inherited version of this view will have a slightly different behaviour, + this view contains multiple methods that can be overriden for this adaption. + """ model = AKCategory context_object_name = "categories" template_name = "AKSubmission/ak_overview.html" wishes_as_category = False - def filter_aks(self, context, category): + def filter_aks(self, context, category): # pylint: disable=unused-argument + """ + Filter which AKs to display based on the given context and category + + In the default case, all AKs of that category are returned (including wishes) + + :param context: context of the view + :param category: category to filter the AK list for + :return: filtered list of AKs for the given category + :rtype: QuerySet[AK] + """ + # Use prefetching and relation selection/joining to reduce the amount of necessary queries return category.ak_set.select_related('event').prefetch_related('owners').all() def get_active_category_name(self, context): + """ + Get the category name to display by default/before further user interaction + + In the default case, simply the first category (the one with the lowest ID for this event) is used + + :param context: context of the view + :return: name of the default category + :rtype: str + """ return context["categories_with_aks"][0][0].name - def get_table_title(self, context): + def get_table_title(self, context): # pylint: disable=unused-argument + """ + Specify the title above the AK list/table in this view + + :param context: context of the view + :return: title to use + :rtype: str + """ return _("All AKs") def get(self, request, *args, **kwargs): + """ + Handle GET request + + Overriden to allow checking for correct configuration and + redirect to error page if necessary (see :class:`SubmissionErrorNotConfiguredView`) + """ self._load_event() - self.object_list = self.get_queryset() + self.object_list = self.get_queryset() # pylint: disable=attribute-defined-outside-init # No categories yet? Redirect to configuration error page if self.object_list.count() == 0: @@ -55,10 +103,16 @@ class AKOverviewView(FilterByEventSlugMixin, ListView): def get_context_data(self, *, object_list=None, **kwargs): context = super().get_context_data(object_list=object_list, **kwargs) + # ========================================================== # Sort AKs into different lists (by their category) + # ========================================================== ak_wishes = [] categories_with_aks = [] + # Loop over categories, load AKs (while filtering them if necessary) and create a list of (category, aks)-tuples + # Depending on the setting of self.wishes_as_category, wishes are either included + # or added to a special "Wish"-Category that is created on-the-fly to provide consistent handling in the + # template (without storing it in the database) for category in context["categories"]: aks_for_category = [] for ak in self.filter_aks(context, category): @@ -76,7 +130,9 @@ class AKOverviewView(FilterByEventSlugMixin, ListView): context["active_category"] = self.get_active_category_name(context) context['table_title'] = self.get_table_title(context) + # ========================================================== # Display interest indication button? + # ========================================================== current_timestamp = datetime.now().astimezone(self.event.timezone) context['interest_indication_active'] = ak_interest_indication_active(self.event, current_timestamp) @@ -84,12 +140,30 @@ class AKOverviewView(FilterByEventSlugMixin, ListView): class SubmissionOverviewView(AKOverviewView): + """ + View: List of AKs and possibility to add AKs or adapt owner information + + Main/start view of the component. + + This view inherits from :class:`AKOverviewView`, but treats wishes as separate category if requested in the settings + and handles the change actions mentioned above. + """ model = AKCategory context_object_name = "categories" template_name = "AKSubmission/submission_overview.html" + + # this mainly steers the different handling of wishes + # since the code for that is already included in the parent class wishes_as_category = settings.WISHES_AS_CATEGORY def get_table_title(self, context): + """ + Specify the title above the AK list/table in this view + + :param context: context of the view + :return: title to use + :rtype: str + """ return _("Currently planned AKs") def get_context_data(self, *, object_list=None, **kwargs): @@ -102,32 +176,71 @@ class SubmissionOverviewView(AKOverviewView): class AKListByCategoryView(AKOverviewView): + """ + View: List of only the AKs belonging to a certain category. + + This view inherits from :class:`AKOverviewView`, but produces only one list instead of a tabbed one. + """ def dispatch(self, request, *args, **kwargs): - self.category = get_object_or_404(AKCategory, pk=kwargs['category_pk']) + # Override dispatching + # Needed to handle the checking whether the category exists + self.category = get_object_or_404(AKCategory, pk=kwargs['category_pk']) # pylint: disable=attribute-defined-outside-init,line-too-long return super().dispatch(request, *args, **kwargs) def get_active_category_name(self, context): + """ + Get the category name to display by default/before further user interaction + + In this case, this will be the name of the category specified via pk + + :param context: context of the view + :return: name of the category + :rtype: str + """ return self.category.name class AKListByTrackView(AKOverviewView): + """ + View: List of only the AKs belonging to a certain track. + + This view inherits from :class:`AKOverviewView` and there will be one list per category + -- but only AKs of a certain given track will be included in them. + """ def dispatch(self, request, *args, **kwargs): - self.track = get_object_or_404(AKTrack, pk=kwargs['track_pk']) + # Override dispatching + # Needed to handle the checking whether the track exists + + self.track = get_object_or_404(AKTrack, pk=kwargs['track_pk']) # pylint: disable=attribute-defined-outside-init return super().dispatch(request, *args, **kwargs) def filter_aks(self, context, category): - return category.ak_set.filter(track=self.track) + """ + Filter which AKs to display based on the given context and category + + In this case, the list is further restricted by the track + + :param context: context of the view + :param category: category to filter the AK list for + :return: filtered list of AKs for the given category + :rtype: QuerySet[AK] + """ + return super().filter_aks(context, category).filter(track=self.track) def get_table_title(self, context): return f"{_('AKs with Track')} = {self.track.name}" class AKDetailView(EventSlugMixin, DetailView): + """ + View: AK Details + """ model = AK context_object_name = "ak" template_name = "AKSubmission/ak_detail.html" def get_queryset(self): + # Get information about the AK and do some query optimization return super().get_queryset().select_related('event').prefetch_related('owners') def get_context_data(self, *, object_list=None, **kwargs): @@ -163,29 +276,34 @@ class AKDetailView(EventSlugMixin, DetailView): class AKHistoryView(EventSlugMixin, DetailView): + """ + View: Show history of a given AK + """ model = AK context_object_name = "ak" template_name = "AKSubmission/ak_history.html" -class AKListView(FilterByEventSlugMixin, ListView): - model = AK - context_object_name = "AKs" - template_name = "AKSubmission/ak_overview.html" - table_title = "" - - def get_context_data(self, *, object_list=None, **kwargs): - context = super().get_context_data(object_list=object_list, **kwargs) - context['categories'] = AKCategory.objects.filter(event=self.event) - context['tracks'] = AKTrack.objects.filter(event=self.event) - return context - - class EventInactiveRedirectMixin: + """ + Mixin that will cause a redirect when actions are performed on an inactive event. + Will add a message explaining why the action was not performed to the user + and then redirect to start page of the submission component + """ def get_error_message(self): + """ + Error message to display after redirect (can be adjusted by this method) + + :return: error message + :rtype: str + """ return _("Event inactive. Cannot create or update.") def get(self, request, *args, **kwargs): + """ + Override GET request handling + Will either perform the redirect including the message creation or continue with the planned dispatching + """ s = super().get(request, *args, **kwargs) if not self.event.active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) @@ -194,6 +312,11 @@ class EventInactiveRedirectMixin: class AKAndAKWishSubmissionView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): + """ + View: Submission form for AKs and Wishes + + Base view, will be used by :class:`AKSubmissionView` and :class:`AKWishSubmissionView` + """ model = AK template_name = 'AKSubmission/submit_new.html' form_class = AKSubmissionForm @@ -221,7 +344,14 @@ class AKAndAKWishSubmissionView(EventSlugMixin, EventInactiveRedirectMixin, Crea class AKSubmissionView(AKAndAKWishSubmissionView): + """ + View: AK submission form + + Extends :class:`AKAndAKWishSubmissionView` + """ def get_initial(self): + # Load initial values for the form + # Used to directly add the first owner and the event this AK will belong to initials = super(AKAndAKWishSubmissionView, self).get_initial() initials['owners'] = [AKOwner.get_by_slug(self.event, self.kwargs['owner_slug'])] initials['event'] = self.event @@ -234,33 +364,54 @@ class AKSubmissionView(AKAndAKWishSubmissionView): class AKWishSubmissionView(AKAndAKWishSubmissionView): + """ + View: Wish submission form + + Extends :class:`AKAndAKWishSubmissionView` + """ template_name = 'AKSubmission/submit_new_wish.html' form_class = AKWishForm def get_initial(self): + # Load initial values for the form + # Used to directly select the event this AK will belong to initials = super(AKAndAKWishSubmissionView, self).get_initial() initials['event'] = self.event return initials class AKEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): + """ + View: Update an AK + + This allows to change most fields of an AK as specified in :class:`AKSubmission.forms.AKForm`, + including the availabilities. + It will also handle the change from AK to wish and vice versa (triggered by adding or removing owners) + and automatically create or delete (unscheduled) slots + """ model = AK template_name = 'AKSubmission/ak_edit.html' form_class = AKForm def get_success_url(self): + # Redirection after successfully saving to detail page of AK where also a success message is displayed messages.add_message(self.request, messages.SUCCESS, _("AK successfully updated")) return self.object.detail_url def form_valid(self, form): + # Handle valid form submission + + # Only save when event is active, otherwise redirect if not form.cleaned_data["event"].active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) return redirect(reverse_lazy('submit:submission_overview', kwargs={'event_slug': form.cleaned_data["event"].slug})) + # Remember owner count before saving to know whether the AK changed its state between AK and wish previous_owner_count = self.object.owners.count() - super_form_valid = super().form_valid(form) + # Perform saving and redirect handling by calling default/parent implementation of form_valid + redirect_response = super().form_valid(form) # Did this AK change from wish to AK or vice versa? new_owner_count = self.object.owners.count() @@ -273,15 +424,23 @@ class AKEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): # Delete all unscheduled slots self.object.akslot_set.filter(start__isnull=True).delete() - return super_form_valid + # Redirect to success url + return redirect_response class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): + """ + View: Create a new owner + """ model = AKOwner template_name = 'AKSubmission/akowner_create_update.html' form_class = AKOwnerForm def get_success_url(self): + # The redirect url depends on the source this view was called from: + + # Called from an existing AK? Add the new owner as an owner of that AK, notify the user and redirect to detail + # page of that AK if "add_to_existing_ak" in self.request.GET: ak_pk = self.request.GET['add_to_existing_ak'] ak = get_object_or_404(AK, pk=ak_pk) @@ -289,15 +448,20 @@ class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): messages.add_message(self.request, messages.SUCCESS, _("Added '{owner}' as new owner of '{ak.name}'").format(owner=self.object, ak=ak)) return ak.detail_url + + # Called from the submission overview? Offer the user to create a new AK with the recently created owner + # prefilled as owner of that AK in the creation form return reverse_lazy('submit:submit_ak', kwargs={'event_slug': self.kwargs['event_slug'], 'owner_slug': self.object.slug}) def get_initial(self): - initials = super(AKOwnerCreateView, self).get_initial() + # Set the event in the (hidden) event field in the form based on the URL this view was called with + initials = super().get_initial() initials['event'] = self.event return initials def form_valid(self, form): + # Prevent changes if event is not active if not form.cleaned_data["event"].active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) return redirect(reverse_lazy('submit:submission_overview', @@ -305,29 +469,98 @@ class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): return super().form_valid(form) -class AKOwnerSelectDispatchView(EventSlugMixin, View): +class AKOwnerDispatchView(ABC, EventSlugMixin, View): """ - This view only serves as redirect to prepopulate the owners field in submission create view + Base view: Dispatch to correct view based upon + + Will be used by :class:`AKOwnerSelectDispatchView` and :class:`AKOwnerEditDispatchView` to handle button clicks for + "New AK" and "Edit Person Info" in submission overview based upon the selection in the owner dropdown field """ + @abstractmethod + def get_new_owner_redirect(self, event_slug): + """ + Get redirect when user selected "I do not own AKs yet" + + :param event_slug: slug of the event, needed for constructing redirect + :return: redirect to perform + :rtype: HttpResponseRedirect + """ + + @abstractmethod + def get_valid_owner_redirect(self, event_slug, owner): + """ + Get redirect when user selected "I do not own AKs yet" + + :param event_slug: slug of the event, needed for constructing redirect + :param owner: owner to perform the dispatching for + :return: redirect to perform + :rtype: HttpResponseRedirect + """ + + def post(self, request, *args, **kwargs): + # This view is solely meant to handle POST requests + # Perform dispatching based on the submitted owner_id + + # No owner_id? Redirect to submission overview view if "owner_id" not in request.POST: return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) owner_id = request.POST["owner_id"] + # Special owner_id "-1" (value of "I do not own AKs yet)? Redirect to owner creation view if owner_id == "-1": - return HttpResponseRedirect( - reverse_lazy('submit:akowner_create', kwargs={'event_slug': kwargs['event_slug']})) + return self.get_new_owner_redirect(kwargs['event_slug']) + # Normal owner_id given? Check vor validity and redirect to AK submission page with that owner prefilled + # or display a 404 error page if no owner for the given id can be found. The latter should only happen when the + # user manipulated the value before sending or when the owner was deleted in backend and the user did not + # reload the dropdown between deletion and sending the dispatch request owner = get_object_or_404(AKOwner, pk=request.POST["owner_id"]) - return HttpResponseRedirect( - reverse_lazy('submit:submit_ak', kwargs={'event_slug': kwargs['event_slug'], 'owner_slug': owner.slug})) + return self.get_valid_owner_redirect(kwargs['event_slug'], owner) def get(self, request, *args, **kwargs): + # This view should never be called with GET, perform a redirect to overview in that case return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) -class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView): +class AKOwnerSelectDispatchView(AKOwnerDispatchView): + """ + View: Handle submission from the owner selection dropdown in submission overview for AK creation + ("New AK" button) + + This view will perform redirects depending on the selection in the owner dropdown field. + Based upon the abstract base view :class:`AKOwnerDispatchView`. + """ + + def get_new_owner_redirect(self, event_slug): + return redirect('submit:akowner_create', event_slug=event_slug) + + def get_valid_owner_redirect(self, event_slug, owner): + return redirect('submit:submit_ak', event_slug=event_slug, owner_slug=owner.slug) + + +class AKOwnerEditDispatchView(AKOwnerDispatchView): + """ + View: Handle submission from the owner selection dropdown in submission overview for owner editing + ("Edit Person Info" button) + + This view will perform redirects depending on the selection in the owner dropdown field. + Based upon the abstract base view :class:`AKOwnerDispatchView`. + """ + + def get_new_owner_redirect(self, event_slug): + messages.add_message(self.request, messages.WARNING, _("No user selected")) + return redirect('submit:submission_overview', event_slug) + + def get_valid_owner_redirect(self, event_slug, owner): + return redirect('submit:akowner_edit', event_slug=event_slug, slug=owner.slug) + + +class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, EventInactiveRedirectMixin, UpdateView): + """ + View: Edit an owner + """ model = AKOwner template_name = "AKSubmission/akowner_create_update.html" form_class = AKOwnerForm @@ -337,6 +570,7 @@ class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView): return reverse_lazy('submit:submission_overview', kwargs={'event_slug': self.kwargs['event_slug']}) def form_valid(self, form): + # Prevent updating if event is not active if not form.cleaned_data["event"].active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) return redirect(reverse_lazy('submit:submission_overview', @@ -344,36 +578,19 @@ class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView): return super().form_valid(form) -class AKOwnerEditDispatchView(EventSlugMixin, View): - """ - This view only serves as redirect choose the correct edit view +class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): """ + View: Add an additional slot to an AK + The user has to select the duration of the slot in this view - def post(self, request, *args, **kwargs): - if "owner_id" not in request.POST: - return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) - owner_id = request.POST["owner_id"] - - if owner_id == "-1": - messages.add_message(self.request, messages.WARNING, _("No user selected")) - return HttpResponseRedirect( - reverse_lazy('submit:submission_overview', kwargs={'event_slug': kwargs['event_slug']})) - - owner = get_object_or_404(AKOwner, pk=request.POST["owner_id"]) - return HttpResponseRedirect( - reverse_lazy('submit:akowner_edit', kwargs={'event_slug': kwargs['event_slug'], 'slug': owner.slug})) - - def get(self, request, *args, **kwargs): - return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) - - -class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): + The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`) + """ model = AKSlot form_class = AKDurationForm template_name = "AKSubmission/akslot_add_update.html" def get_initial(self): - initials = super(AKSlotAddView, self).get_initial() + initials = super().get_initial() initials['event'] = self.event initials['ak'] = get_object_or_404(AK, pk=self.kwargs['pk']) initials['duration'] = self.event.default_slot @@ -390,6 +607,12 @@ class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): class AKSlotEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): + """ + View: Update the duration of an AK slot + + The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`) + and only slots that are not scheduled yet may be changed + """ model = AKSlot form_class = AKDurationForm template_name = "AKSubmission/akslot_add_update.html" @@ -413,6 +636,12 @@ class AKSlotEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): class AKSlotDeleteView(EventSlugMixin, EventInactiveRedirectMixin, DeleteView): + """ + View: Delete an AK slot + + The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`) + and only slots that are not scheduled yet may be deleted + """ model = AKSlot template_name = "AKSubmission/akslot_delete.html" @@ -436,6 +665,11 @@ class AKSlotDeleteView(EventSlugMixin, EventInactiveRedirectMixin, DeleteView): @status_manager.register(name="event_ak_messages") class EventAKMessagesWidget(TemplateStatusWidget): + """ + Status page widget: AK Messages + + A widget to display information about AK-related messages sent to organizers for the given event + """ required_context_type = "event" title = _("Messages") template_name = "admin/AKModel/render_ak_messages.html" @@ -454,12 +688,16 @@ class EventAKMessagesWidget(TemplateStatusWidget): class AKAddOrgaMessageView(EventSlugMixin, CreateView): + """ + View: Form to create a (confidential) message to the organizers as defined in + :class:`AKSubmission.forms.AKOrgaMessageForm` + """ model = AKOrgaMessage form_class = AKOrgaMessageForm template_name = "AKSubmission/akmessage_add.html" def get_initial(self): - initials = super(AKAddOrgaMessageView, self).get_initial() + initials = super().get_initial() initials['ak'] = get_object_or_404(AK, pk=self.kwargs['pk']) initials['event'] = initials['ak'].event return initials