From 2b7f93149f58fe9e4a8de09891eb3d840f0d33d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20H=C3=A4ttasch?= <benjamin.haettasch@fachschaft.informatik.tu-darmstadt.de> Date: Thu, 10 Aug 2023 14:41:35 +0200 Subject: [PATCH] Improve AKModel Add or complete docstrings Remove code smells Disable irrelevant warnings Update translations to changed line numbers and line breaks Move duplicated code for event field pre-population and event timezone adaption to mixins --- AKModel/admin.py | 318 ++++++++---- AKModel/apps.py | 7 + AKModel/availability/forms.py | 72 ++- AKModel/availability/models.py | 29 +- AKModel/availability/serializers.py | 24 +- AKModel/environment.py | 15 +- AKModel/forms.py | 83 ++- AKModel/locale/de_DE/LC_MESSAGES/django.po | 526 ++++++++++---------- AKModel/management/commands/makemessages.py | 22 +- AKModel/metaviews/__init__.py | 2 + AKModel/metaviews/admin.py | 109 +++- AKModel/metaviews/status.py | 47 +- AKModel/models.py | 260 ++++++++-- AKModel/serializers.py | 21 + AKModel/site.py | 11 +- AKModel/templatetags/tags_AKModel.py | 49 +- AKModel/tests.py | 115 ++++- AKModel/urls.py | 22 +- AKModel/views/ak.py | 34 +- AKModel/views/api.py | 24 + AKModel/views/event_wizard.py | 55 +- AKModel/views/manage.py | 53 +- AKModel/views/room.py | 32 +- AKModel/views/status.py | 45 +- 24 files changed, 1479 insertions(+), 496 deletions(-) diff --git a/AKModel/admin.py b/AKModel/admin.py index 0d48bc6f..29c14a05 100644 --- a/AKModel/admin.py +++ b/AKModel/admin.py @@ -18,12 +18,15 @@ from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, A ConstraintViolation, DefaultSlot from AKModel.urls import get_admin_urls_event_wizard, get_admin_urls_event from AKModel.views.ak import AKResetInterestView, AKResetInterestCounterView -from AKModel.views.manage import PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, CVMarkResolvedView, \ - CVSetLevelViolationView, CVSetLevelWarningView -from AKModel.views.room import RoomBatchCreationView +from AKModel.views.manage import CVMarkResolvedView, CVSetLevelViolationView, CVSetLevelWarningView class EventRelatedFieldListFilter(RelatedFieldListFilter): + """ + Reusable filter to restrict the possible choices of a field to those belonging to a certain event + as specified in the event__id__exact GET parameter. + The choices are only restricted if this parameter is present, otherwise all choices are used/returned + """ def field_choices(self, field, request, model_admin): ordering = self.field_admin_ordering(field, request, model_admin) limit_choices = {} @@ -34,6 +37,17 @@ class EventRelatedFieldListFilter(RelatedFieldListFilter): @admin.register(Event) class EventAdmin(admin.ModelAdmin): + """ + Admin interface for Event + + This allows to edit most fields of an event, some can only be changed by admin actions, since they have side effects + + This admin interface registers additional views as defined in urls.py, the wizard, and the full scheduling + functionality if the AKScheduling app is active. + + The interface overrides the built-in creation interface for a new event and replaces it with the event creation + wizard. + """ model = Event list_display = ['name', 'status_url', 'place', 'start', 'end', 'active', 'plan_hidden'] list_filter = ['active'] @@ -43,32 +57,54 @@ class EventAdmin(admin.ModelAdmin): actions = ['publish', 'unpublish'] def add_view(self, request, form_url='', extra_context=None): - # Always use wizard to create new events (the built-in form wouldn't work anyways since the timezone cannot + # Override + # Always use wizard to create new events (the built-in form wouldn't work anyway since the timezone cannot # be specified before starting to fill the form) return redirect("admin:new_event_wizard_start") def get_urls(self): + """ + Get all event-related URLs + This will be both the built-in URLs and additional views providing additional functionality + :return: list of all relevant URLs + :rtype: List[path] + """ + # Load wizard URLs and the additional URLs defined in urls.py + # (first, to have the highest priority when overriding views) urls = get_admin_urls_event_wizard(self.admin_site) urls.extend(get_admin_urls_event(self.admin_site)) + + # Make scheduling admin views available if app is active if apps.is_installed("AKScheduling"): - from AKScheduling.urls import get_admin_urls_scheduling + from AKScheduling.urls import get_admin_urls_scheduling # pylint: disable=import-outside-toplevel urls.extend(get_admin_urls_scheduling(self.admin_site)) - urls.extend([ - path('plan/publish/', self.admin_site.admin_view(PlanPublishView.as_view()), name="plan-publish"), - path('plan/unpublish/', self.admin_site.admin_view(PlanUnpublishView.as_view()), name="plan-unpublish"), - path('<slug:event_slug>/defaultSlots/', self.admin_site.admin_view(DefaultSlotEditorView.as_view()), name="default-slots-editor"), - path('<slug:event_slug>/importRooms/', self.admin_site.admin_view(RoomBatchCreationView.as_view()), name="room-import"), - ]) + + # Make sure built-in URLs are available as well urls.extend(super().get_urls()) return urls @display(description=_("Status")) def status_url(self, obj): + """ + Define a read-only field to go to the status page of the event + + :param obj: the event to link + :return: status page link (HTML) + :rtype: str + """ return format_html("<a href='{url}'>{text}</a>", url=reverse_lazy('admin:event_status', kwargs={'event_slug': obj.slug}), text=_("Status")) @display(description=_("Toggle plan visibility")) def toggle_plan_visibility(self, obj): + """ + Define a read-only field to toggle the visibility of the plan of this event + This will choose from two different link targets/views depending on the current visibility status + + :param obj: event to change the visibility of the plan for + :return: toggling link (HTML) + :rtype: str + """ if obj.plan_hidden: url = f"{reverse_lazy('admin:plan-publish')}?pks={obj.pk}" text = _('Publish plan') @@ -78,78 +114,97 @@ class EventAdmin(admin.ModelAdmin): return format_html("<a href='{url}'>{text}</a>", url=url, text=text) def get_form(self, request, obj=None, change=False, **kwargs): - # Use timezone of event + # Override (update) form rendering to make sure the timezone of the event is used timezone.activate(obj.timezone) return super().get_form(request, obj, change, **kwargs) @action(description=_('Publish plan')) def publish(self, request, queryset): + """ + Admin action to publish the plan + """ selected = queryset.values_list('pk', flat=True) return HttpResponseRedirect(f"{reverse_lazy('admin:plan-publish')}?pks={','.join(str(pk) for pk in selected)}") @action(description=_('Unpublish plan')) def unpublish(self, request, queryset): + """ + Admin action to hide the plan + """ selected = queryset.values_list('pk', flat=True) - return HttpResponseRedirect(f"{reverse_lazy('admin:plan-unpublish')}?pks={','.join(str(pk) for pk in selected)}") + return HttpResponseRedirect( + f"{reverse_lazy('admin:plan-unpublish')}?pks={','.join(str(pk) for pk in selected)}") + + +class PrepopulateWithNextActiveEventMixin: + """ + Mixin for automated pre-population of the event field + """ + # pylint: disable=too-few-public-methods + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """ + Override field generation for foreign key fields to introduce special handling for event fields: + Pre-populate the event field with the next active event (since that is the most likeliest event to be worked + on in the admin interface) to make creation of new owners easier + """ + if db_field.name == 'event': + kwargs['initial'] = Event.get_next_active() + return super().formfield_for_foreignkey(db_field, request, **kwargs) @admin.register(AKOwner) -class AKOwnerAdmin(admin.ModelAdmin): +class AKOwnerAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for AKOwner + """ model = AKOwner list_display = ['name', 'institution', 'event'] list_filter = ['event', 'institution'] list_editable = [] ordering = ['name'] - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == 'event': - kwargs['initial'] = Event.get_next_active() - return super(AKOwnerAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) - @admin.register(AKCategory) -class AKCategoryAdmin(admin.ModelAdmin): +class AKCategoryAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for AKCategory + """ model = AKCategory list_display = ['name', 'color', 'event'] list_filter = ['event'] list_editable = ['color'] ordering = ['name'] - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == 'event': - kwargs['initial'] = Event.get_next_active() - return super(AKCategoryAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) - @admin.register(AKTrack) -class AKTrackAdmin(admin.ModelAdmin): +class AKTrackAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for AKTrack + """ model = AKTrack list_display = ['name', 'color', 'event'] list_filter = ['event'] list_editable = ['color'] ordering = ['name'] - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == 'event': - kwargs['initial'] = Event.get_next_active() - return super(AKTrackAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) - @admin.register(AKRequirement) -class AKRequirementAdmin(admin.ModelAdmin): +class AKRequirementAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for AKRequirements + """ model = AKRequirement list_display = ['name', 'event'] list_filter = ['event'] list_editable = [] ordering = ['name'] - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == 'event': - kwargs['initial'] = Event.get_next_active() - return super(AKRequirementAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) - class WishFilter(SimpleListFilter): + """ + Re-usable filter for wishes + """ title = _("Wish") # a label for our filter parameter_name = 'wishes' # you can put anything here @@ -170,6 +225,9 @@ class WishFilter(SimpleListFilter): class AKAdminForm(forms.ModelForm): + """ + Modified admin form for AKs, to be used in :class:`AKAdmin` + """ class Meta: widgets = { 'requirements': forms.CheckboxSelectMultiple, @@ -188,10 +246,19 @@ class AKAdminForm(forms.ModelForm): @admin.register(AK) -class AKAdmin(SimpleHistoryAdmin): +class AKAdmin(PrepopulateWithNextActiveEventMixin, SimpleHistoryAdmin): + """ + Admin interface for AKs + + Uses a modified form (see :class:`AKAdminForm`) + """ model = AK list_display = ['name', 'short_name', 'category', 'track', 'is_wish', 'interest', 'interest_counter', 'event'] - list_filter = ['event', WishFilter, ('category', EventRelatedFieldListFilter), ('requirements', EventRelatedFieldListFilter)] + list_filter = ['event', + WishFilter, + ('category', EventRelatedFieldListFilter), + ('requirements', EventRelatedFieldListFilter) + ] list_editable = ['short_name', 'track', 'interest_counter'] ordering = ['pk'] actions = ['wiki_export', 'reset_interest', 'reset_interest_counter'] @@ -199,25 +266,36 @@ class AKAdmin(SimpleHistoryAdmin): @display(boolean=True) def is_wish(self, obj): + """ + Property: Is this AK a wish? + """ return obj.wish @action(description=_("Export to wiki syntax")) def wiki_export(self, request, queryset): + """ + Action: Export to wiki syntax + This will use the wiki export view (therefore, all AKs have to have the same event to correclty handle the + categories and to prevent accidentially merging AKs from different events in the wiki) + but restrict the AKs to the ones explicitly selected here. + """ # Only export when all AKs belong to the same event if queryset.values("event").distinct().count() == 1: event = queryset.first().event pks = set(ak.pk for ak in queryset.all()) - categories_with_aks = event.get_categories_with_aks(wishes_seperately=False, filter=lambda ak: ak.pk in pks, + categories_with_aks = event.get_categories_with_aks(wishes_seperately=False, + filter_func=lambda ak: ak.pk in pks, hide_empty_categories=True) - return render(request, 'admin/AKModel/wiki_export.html', context={"categories_with_aks": categories_with_aks}) + return render(request, 'admin/AKModel/wiki_export.html', + context={"categories_with_aks": categories_with_aks}) self.message_user(request, _("Cannot export AKs from more than one event at the same time."), messages.ERROR) - - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == 'event': - kwargs['initial'] = Event.get_next_active() - return super(AKAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) + return redirect('admin:AKModel_ak_changelist') def get_urls(self): + """ + Add additional URLs/views + Currently used to reset the interest field and interest counter field + """ urls = [ path('reset-interest/', AKResetInterestView.as_view(), name="ak-reset-interest"), path('reset-interest-counter/', AKResetInterestCounterView.as_view(), name="ak-reset-interest-counter"), @@ -227,17 +305,30 @@ class AKAdmin(SimpleHistoryAdmin): @action(description=_("Reset interest in AKs")) def reset_interest(self, request, queryset): + """ + Action: Reset interest field for the given AKs + Will use a typical admin confirmation view flow + """ selected = queryset.values_list('pk', flat=True) - return HttpResponseRedirect(f"{reverse_lazy('admin:ak-reset-interest')}?pks={','.join(str(pk) for pk in selected)}") + return HttpResponseRedirect( + f"{reverse_lazy('admin:ak-reset-interest')}?pks={','.join(str(pk) for pk in selected)}") @action(description=_("Reset AKs' interest counters")) def reset_interest_counter(self, request, queryset): + """ + Action: Reset interest counter field for the given AKs + Will use a typical admin confirmation view flow + """ selected = queryset.values_list('pk', flat=True) - return HttpResponseRedirect(f"{reverse_lazy('admin:ak-reset-interest-counter')}?pks={','.join(str(pk) for pk in selected)}") + return HttpResponseRedirect( + f"{reverse_lazy('admin:ak-reset-interest-counter')}?pks={','.join(str(pk) for pk in selected)}") @admin.register(Room) -class RoomAdmin(admin.ModelAdmin): +class RoomAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for Rooms + """ model = Room list_display = ['name', 'location', 'capacity', 'event'] list_filter = ['event', ('properties', EventRelatedFieldListFilter), 'location'] @@ -246,26 +337,29 @@ class RoomAdmin(admin.ModelAdmin): change_form_template = "admin/AKModel/room_change_form.html" def add_view(self, request, form_url='', extra_context=None): + # Override creation view # Use custom view for room creation (either room form or combined form if virtual rooms are supported) return redirect("admin:room-new") def get_form(self, request, obj=None, change=False, **kwargs): + # Override form creation to use a form that allows to specify availabilites of the room once this room is + # associated with an event (so not before the first saving) since the timezone information and event start + # and end are needed to correclty render the calendar if obj is not None: return RoomFormWithAvailabilities return super().get_form(request, obj, change, **kwargs) - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == 'event': - kwargs['initial'] = Event.get_next_active() - return super(RoomAdmin, self).formfield_for_foreignkey( - db_field, request, **kwargs - ) - def get_urls(self): + """ + Add additional URLs/views + This is currently used to adapt the creation form behavior, to allow the creation of virtual rooms in-place + when the support for virtual rooms is turned on (AKOnline app active) + """ + # pylint: disable=import-outside-toplevel if apps.is_installed("AKOnline"): from AKOnline.views import RoomCreationWithVirtualView as RoomCreationView else: - from .views import RoomCreationView + from .views.room import RoomCreationView urls = [ path('new/', self.admin_site.admin_view(RoomCreationView.as_view()), name="room-new"), @@ -274,7 +368,28 @@ class RoomAdmin(admin.ModelAdmin): return urls +class EventTimezoneFormMixin: + """ + Mixin to enforce the usage of the timezone of the associated event in forms + """ + # pylint: disable=too-few-public-methods + + def get_form(self, request, obj=None, change=False, **kwargs): + """ + Override form creation, use timezone of associated event + """ + if obj is not None and obj.event.timezone: + timezone.activate(obj.event.timezone) + # No timezone available? Use UTC + else: + timezone.activate("UTC") + return super().get_form(request, obj, change, **kwargs) + + class AKSlotAdminForm(forms.ModelForm): + """ + Modified admin form for AKSlots, to be used in :class:`AKSlotAdmin` + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Filter possible values for foreign keys when event is specified @@ -284,7 +399,12 @@ class AKSlotAdminForm(forms.ModelForm): @admin.register(AKSlot) -class AKSlotAdmin(admin.ModelAdmin): +class AKSlotAdmin(EventTimezoneFormMixin, PrepopulateWithNextActiveEventMixin, admin.ModelAdmin): + """ + Admin interface for AKSlots + + Uses a modified form (see :class:`AKSlotAdminForm`) + """ model = AKSlot list_display = ['id', 'ak', 'room', 'start', 'duration', 'event'] list_filter = ['event', ('room', EventRelatedFieldListFilter)] @@ -292,22 +412,15 @@ class AKSlotAdmin(admin.ModelAdmin): readonly_fields = ['ak_details_link', 'updated'] form = AKSlotAdminForm - def get_form(self, request, obj=None, change=False, **kwargs): - # Use timezone of associated event - if obj is not None and obj.event.timezone: - timezone.activate(obj.event.timezone) - # No timezone available? Use UTC - else: - timezone.activate("UTC") - return super().get_form(request, obj, change, **kwargs) - - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if db_field.name == 'event': - kwargs['initial'] = Event.get_next_active() - return super(AKSlotAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) - @display(description=_('AK Details')) def ak_details_link(self, akslot): + """ + Define a read-only field to link the details of the associated AK + + :param obj: the AK to link + :return: AK detail page page link (HTML) + :rtype: str + """ if apps.is_installed("AKSubmission") and akslot.ak is not None: link = f"<a href={{ akslot.detail_url }}>{str(akslot.ak)}</a>" return mark_safe(link) @@ -317,25 +430,28 @@ class AKSlotAdmin(admin.ModelAdmin): @admin.register(Availability) -class AvailabilityAdmin(admin.ModelAdmin): - def get_form(self, request, obj=None, change=False, **kwargs): - # Use timezone of associated event - if obj is not None and obj.event.timezone: - timezone.activate(obj.event.timezone) - # No timezone available? Use UTC - else: - timezone.activate("UTC") - return super().get_form(request, obj, change, **kwargs) +class AvailabilityAdmin(EventTimezoneFormMixin, admin.ModelAdmin): + """ + Admin interface for Availabilities + """ + list_display = ['__str__', 'event'] + list_filter = ['event'] @admin.register(AKOrgaMessage) class AKOrgaMessageAdmin(admin.ModelAdmin): + """ + Admin interface for AKOrgaMessages + """ list_display = ['timestamp', 'ak', 'text'] list_filter = ['ak__event'] readonly_fields = ['timestamp', 'ak', 'text'] class ConstraintViolationAdminForm(forms.ModelForm): + """ + Adapted admin form for constraint violations for usage in :class:`ConstraintViolationAdmin`) + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Filter possible values for foreign keys & m2m when event is specified @@ -350,6 +466,10 @@ class ConstraintViolationAdminForm(forms.ModelForm): @admin.register(ConstraintViolation) class ConstraintViolationAdmin(admin.ModelAdmin): + """ + Admin interface for constraint violations + Uses an adapted form (see :class:`ConstraintViolationAdminForm`) + """ list_display = ['type', 'level', 'get_details', 'manually_resolved'] list_filter = ['event'] readonly_fields = ['timestamp'] @@ -357,6 +477,9 @@ class ConstraintViolationAdmin(admin.ModelAdmin): actions = ['mark_resolved', 'set_violation', 'set_warning'] def get_urls(self): + """ + Add additional URLs/views to change status and severity of CVs + """ urls = [ path('mark-resolved/', CVMarkResolvedView.as_view(), name="cv-mark-resolved"), path('set-violation/', CVSetLevelViolationView.as_view(), name="cv-set-violation"), @@ -367,21 +490,36 @@ class ConstraintViolationAdmin(admin.ModelAdmin): @action(description=_("Mark Constraint Violations as manually resolved")) def mark_resolved(self, request, queryset): + """ + Action: Mark CV as resolved + """ selected = queryset.values_list('pk', flat=True) - return HttpResponseRedirect(f"{reverse_lazy('admin:cv-mark-resolved')}?pks={','.join(str(pk) for pk in selected)}") + return HttpResponseRedirect( + f"{reverse_lazy('admin:cv-mark-resolved')}?pks={','.join(str(pk) for pk in selected)}") @action(description=_('Set Constraint Violations to level "violation"')) def set_violation(self, request, queryset): + """ + Action: Promote CV to level violation + """ selected = queryset.values_list('pk', flat=True) - return HttpResponseRedirect(f"{reverse_lazy('admin:cv-set-violation')}?pks={','.join(str(pk) for pk in selected)}") + return HttpResponseRedirect( + f"{reverse_lazy('admin:cv-set-violation')}?pks={','.join(str(pk) for pk in selected)}") @action(description=_('Set Constraint Violations to level "warning"')) def set_warning(self, request, queryset): + """ + Action: Set CV to level warning + """ selected = queryset.values_list('pk', flat=True) - return HttpResponseRedirect(f"{reverse_lazy('admin:cv-set-warning')}?pks={','.join(str(pk) for pk in selected)}") + return HttpResponseRedirect( + f"{reverse_lazy('admin:cv-set-warning')}?pks={','.join(str(pk) for pk in selected)}") class DefaultSlotAdminForm(forms.ModelForm): + """ + Adapted admin form for DefaultSlot for usage in :class:`DefaultSlotAdmin` + """ class Meta: widgets = { 'primary_categories': forms.CheckboxSelectMultiple @@ -395,13 +533,11 @@ class DefaultSlotAdminForm(forms.ModelForm): @admin.register(DefaultSlot) -class DefaultSlotAdmin(admin.ModelAdmin): +class DefaultSlotAdmin(EventTimezoneFormMixin, admin.ModelAdmin): + """ + Admin interface for default slots + Uses an adapted form (see :class:`DefaultSlotAdminForm`) + """ list_display = ['start_simplified', 'end_simplified', 'event'] list_filter = ['event'] form = DefaultSlotAdminForm - - def get_form(self, request, obj=None, change=False, **kwargs): - # Use timezone of event - if obj is not None: - timezone.activate(obj.event.timezone) - return super().get_form(request, obj, change, **kwargs) diff --git a/AKModel/apps.py b/AKModel/apps.py index 5af2af0d..8b90f381 100644 --- a/AKModel/apps.py +++ b/AKModel/apps.py @@ -3,8 +3,15 @@ from django.contrib.admin.apps import AdminConfig class AkmodelConfig(AppConfig): + """ + App configuration (default, only specifies name of the app) + """ name = 'AKModel' class AKAdminConfig(AdminConfig): + """ + App configuration for admin + Loading a custom version here allows to add additional contex and further adapt the behavior of the admin interface + """ default_site = 'AKModel.site.AKAdminSite' diff --git a/AKModel/availability/forms.py b/AKModel/availability/forms.py index c6abc863..994949a8 100644 --- a/AKModel/availability/forms.py +++ b/AKModel/availability/forms.py @@ -1,7 +1,7 @@ # This part of the code was adapted from pretalx (https://github.com/pretalx/pretalx) # Copyright 2017-2019, Tobias Kunze # Original Copyrights licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 -# Changes are marked in the code +# Documentation was mainly added by us, other changes are marked in the code import datetime import json @@ -17,6 +17,10 @@ from AKModel.models import Event class AvailabilitiesFormMixin(forms.Form): + """ + Mixin for forms to add availabilities functionality to it + Will handle the rendering and population of an availabilities field + """ availabilities = forms.CharField( label=_('Availability'), help_text=_( @@ -28,6 +32,14 @@ class AvailabilitiesFormMixin(forms.Form): ) def _serialize(self, event, instance): + """ + Serialize relevant availabilities into a JSON format to populate the text field in the form + + :param event: event the availabilities belong to (relevant for start and end times) + :param instance: the entity availabilities in this form should belong to (e.g., an AK, or a Room) + :return: JSON serializiation of the relevant availabilities + :rtype: str + """ if instance: availabilities = AvailabilitySerializer( instance.availabilities.all(), many=True @@ -48,20 +60,28 @@ class AvailabilitiesFormMixin(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Load event information and populate availabilities text field self.event = self.initial.get('event') if isinstance(self.event, int): self.event = Event.objects.get(pk=self.event) - initial = kwargs.pop('initial', dict()) + initial = kwargs.pop('initial', {}) initial['availabilities'] = self._serialize(self.event, kwargs['instance']) if not isinstance(self, forms.BaseModelForm): kwargs.pop('instance') kwargs['initial'] = initial def _parse_availabilities_json(self, jsonavailabilities): + """ + Turn raw JSON availabilities into a list of model instances + + :param jsonavailabilities: raw json input + :return: a list of availability objects corresponding to the raw input + :rtype: List[Availability] + """ try: rawdata = json.loads(jsonavailabilities) - except ValueError: - raise forms.ValidationError("Submitted availabilities are not valid json.") + except ValueError as exc: + raise forms.ValidationError("Submitted availabilities are not valid json.") from exc if not isinstance(rawdata, dict): raise forms.ValidationError( "Submitted json does not comply with expected format, should be object." @@ -74,17 +94,32 @@ class AvailabilitiesFormMixin(forms.Form): return availabilities def _parse_datetime(self, strdate): + """ + Parse input date string + This will try to correct timezone information if needed + + :param strdate: string representing a timestamp + :return: a timestamp object + """ tz = self.event.timezone # adapt to our event model obj = parse_datetime(strdate) if not obj: raise TypeError if obj.tzinfo is None: + # Adapt to new python timezone interface obj = obj.replace(tzinfo=tz) return obj def _validate_availability(self, rawavail): + """ + Validate a raw availability instance input by making sure the relevant fields are present and can be parsed + The cleaned up values that are produced to test the validity of the input are stored in-place in the input + object for later usage in cleaning/parsing to availability objects + + :param rawavail: object to validate/clean + """ message = _("The submitted availability does not comply with the required format.") if not isinstance(rawavail, dict): raise forms.ValidationError(message) @@ -96,12 +131,11 @@ class AvailabilitiesFormMixin(forms.Form): try: rawavail['start'] = self._parse_datetime(rawavail['start']) rawavail['end'] = self._parse_datetime(rawavail['end']) - except (TypeError, ValueError): + # Adapt: Better error handling + except (TypeError, ValueError) as exc: raise forms.ValidationError( _("The submitted availability contains an invalid date.") - ) - - tz = self.event.timezone # adapt to our event model + ) from exc timeframe_start = self.event.start # adapt to our event model if rawavail['start'] < timeframe_start: @@ -115,6 +149,10 @@ class AvailabilitiesFormMixin(forms.Form): rawavail['end'] = timeframe_end def clean_availabilities(self): + """ + Turn raw availabilities into real availability objects + :return: + """ data = self.cleaned_data.get('availabilities') required = ( 'availabilities' in self.fields and self.fields['availabilities'].required @@ -135,7 +173,8 @@ class AvailabilitiesFormMixin(forms.Form): return availabilities def _set_foreignkeys(self, instance, availabilities): - """Set the reference to `instance` in each given availability. + """ + Set the reference to `instance` in each given availability. For example, set the availabilitiy.room_id to instance.id, in case instance of type Room. """ @@ -145,10 +184,20 @@ class AvailabilitiesFormMixin(forms.Form): setattr(avail, reference_name, instance.id) def _replace_availabilities(self, instance, availabilities: [Availability]): + """ + Replace the existing list of availabilities belonging to an entity with a new, updated one + + This will trigger a post_save signal for usage in constraint violation checking + + :param instance: entity the availabilities belong to + :param availabilities: list of new availabilities + """ with transaction.atomic(): - # TODO: do not recreate objects unnecessarily, give the client the IDs, so we can track modifications and leave unchanged objects alone + # TODO: do not recreate objects unnecessarily, give the client the IDs, so we can track modifications and + # leave unchanged objects alone instance.availabilities.all().delete() Availability.objects.bulk_create(availabilities) + # Adaption: # Trigger post save signal manually to make sure constraints are updated accordingly # Doing this one time is sufficient, since this will nevertheless update all availability constraint # violations of the corresponding AK @@ -156,6 +205,9 @@ class AvailabilitiesFormMixin(forms.Form): post_save.send(Availability, instance=availabilities[0], created=True) def save(self, *args, **kwargs): + """ + Override the saving method of the (model) form + """ instance = super().save(*args, **kwargs) availabilities = self.cleaned_data.get('availabilities') diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py index 4f90ddc2..7ce794dc 100644 --- a/AKModel/availability/models.py +++ b/AKModel/availability/models.py @@ -23,6 +23,9 @@ zero_time = datetime.time(0, 0) # add meta class # enable availabilities for AKs and AKCategories # add verbose names and help texts to model attributes +# adapt or extemd documentation + + class Availability(models.Model): """The Availability class models when people or rooms are available for. @@ -31,6 +34,8 @@ class Availability(models.Model): span multiple days, but due to our choice of input widget, it will usually only span a single day at most. """ + # pylint: disable=broad-exception-raised + event = models.ForeignKey( to=Event, related_name='availabilities', @@ -96,10 +101,10 @@ class Availability(models.Model): are the same. """ return all( - [ + ( getattr(self, attribute, None) == getattr(other, attribute, None) for attribute in ['event', 'person', 'room', 'ak', 'ak_category', 'start', 'end'] - ] + ) ) @cached_property @@ -233,10 +238,28 @@ class Availability(models.Model): @property def simplified(self): - return f'{self.start.astimezone(self.event.timezone).strftime("%a %H:%M")}-{self.end.astimezone(self.event.timezone).strftime("%a %H:%M")}' + """ + Get a simplified (only Weekday, hour and minute) string representation of an availability + :return: simplified string version + :rtype: str + """ + return (f'{self.start.astimezone(self.event.timezone).strftime("%a %H:%M")}-' + f'{self.end.astimezone(self.event.timezone).strftime("%a %H:%M")}') @classmethod def with_event_length(cls, event, person=None, room=None, ak=None, ak_category=None): + """ + Create an availability covering exactly the time between event start and event end. + Can e.g., be used to create default availabilities. + + :param event: relevant event + :param person: person, if availability should be connected to a person + :param room: room, if availability should be connected to a room + :param ak: ak, if availability should be connected to a ak + :param ak_category: ak_category, if availability should be connected to a ak_category + :return: availability associated to the entity oder entities selected + :rtype: Availability + """ timeframe_start = event.start # adapt to our event model # add 1 day, not 24 hours, https://stackoverflow.com/a/25427822/2486196 timeframe_end = event.end # adapt to our event model diff --git a/AKModel/availability/serializers.py b/AKModel/availability/serializers.py index 92b3adc6..ae7569db 100644 --- a/AKModel/availability/serializers.py +++ b/AKModel/availability/serializers.py @@ -1,7 +1,7 @@ # This part of the code was adapted from pretalx (https://github.com/pretalx/pretalx) # Copyright 2017-2019, Tobias Kunze # Original Copyrights licensed under the Apache License, Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 -# Changes are marked in the code +# Documentation was mainly added by us, other changes are marked in the code from django.utils import timezone from rest_framework.fields import SerializerMethodField from rest_framework.serializers import ModelSerializer @@ -10,19 +10,35 @@ from AKModel.availability.models import Availability class AvailabilitySerializer(ModelSerializer): + """ + REST Framework Serializer for Availability + """ allDay = SerializerMethodField() start = SerializerMethodField() end = SerializerMethodField() - def get_allDay(self, obj): + def get_allDay(self, obj): # pylint: disable=invalid-name + """ + Bridge between naming conventions of python and fullcalendar by providing the all_day field as allDay, too + """ return obj.all_day - # Use already localized strings in serialized field - # (default would be UTC, but that would require heavy timezone calculation on client side) def get_start(self, obj): + """ + Get start timestamp + + Use already localized strings in serialized field + (default would be UTC, but that would require heavy timezone calculation on client side) + """ return timezone.localtime(obj.start, obj.event.timezone).strftime("%Y-%m-%dT%H:%M:%S") def get_end(self, obj): + """ + Get end timestamp + + Use already localized strings in serialized field + (default would be UTC, but that would require heavy timezone calculation on client side) + """ return timezone.localtime(obj.end, obj.event.timezone).strftime("%Y-%m-%dT%H:%M:%S") class Meta: diff --git a/AKModel/environment.py b/AKModel/environment.py index a6536bee..b476f0d7 100644 --- a/AKModel/environment.py +++ b/AKModel/environment.py @@ -1,11 +1,14 @@ -# environment.py +""" +Environment definitions +Needed for tex compilation +""" import re from django_tex.environment import environment # Used to filter all very special UTF-8 chars that are probably not contained in the LaTeX fonts # and would hence cause compilation errors -utf8_replace_pattern = re.compile(u'[^\u0000-\u206F]', re.UNICODE) +utf8_replace_pattern = re.compile('[^\u0000-\u206F]', re.UNICODE) def latex_escape_utf8(value): @@ -17,12 +20,14 @@ def latex_escape_utf8(value): :return: escaped string :rtype: str """ - return utf8_replace_pattern.sub('', value).replace('&', '\&').replace('_', '\_').replace('#', '\#').replace('$', - '\$').replace( - '%', '\%').replace('{', '\{').replace('}', '\}') + return (utf8_replace_pattern.sub('', value).replace('&', r'\&').replace('_', r'\_').replace('#', r'\#'). + replace('$', r'\$').replace('%', r'\%').replace('{', r'\{').replace('}', r'\}')) def improved_tex_environment(**options): + """ + Encapsulate our improved latex environment for usage + """ env = environment(**options) env.filters.update({ 'latex_escape_utf8': latex_escape_utf8, diff --git a/AKModel/forms.py b/AKModel/forms.py index 606b5753..f5475668 100644 --- a/AKModel/forms.py +++ b/AKModel/forms.py @@ -1,3 +1,7 @@ +""" +Central and admin forms +""" + import csv import io @@ -11,6 +15,17 @@ from AKModel.models import Event, AKCategory, AKRequirement, Room class NewEventWizardStartForm(forms.ModelForm): + """ + Initial view of new event wizard + + This form is a model form for Event, but only with a subset of the required fields. + It is therefore not possible to really create an event using this form, but only to enter + information, in particular the timezone, that is needed to correctly handle/parse the user + inputs for further required fields like start and end of the event. + + The form will be used for this partial input, the input of the remaining required fields + will then be handled by :class:`NewEventWizardSettingsForm` (see below). + """ class Meta: model = Event fields = ['name', 'slug', 'timezone', 'plan_hidden'] @@ -18,13 +33,20 @@ class NewEventWizardStartForm(forms.ModelForm): 'plan_hidden': forms.HiddenInput(), } + # Special hidden field for wizard state handling is_init = forms.BooleanField(initial=True, widget=forms.HiddenInput) class NewEventWizardSettingsForm(forms.ModelForm): + """ + Form for second view of the event creation wizard. + + Will handle the input of the remaining required as well as some optional fields. + See also :class:`NewEventWizardStartForm`. + """ class Meta: model = Event - exclude = [] + fields = "__all__" widgets = { 'name': forms.HiddenInput(), 'slug': forms.HiddenInput(), @@ -38,6 +60,10 @@ class NewEventWizardSettingsForm(forms.ModelForm): class NewEventWizardPrepareImportForm(forms.Form): + """ + Wizard form for choosing an event to import/copy elements (requirements, categories, etc) from. + Is used to restrict the list of elements to choose from in the next step (see :class:`NewEventWizardImportForm`). + """ import_event = forms.ModelChoiceField( queryset=Event.objects.all(), label=_("Copy ak requirements and ak categories of existing event"), @@ -46,6 +72,12 @@ class NewEventWizardPrepareImportForm(forms.Form): class NewEventWizardImportForm(forms.Form): + """ + Wizard form for excaclty choosing which elemments to copy/import for the newly created event. + Possible elements are categories, requirements, and dashboard buttons if AKDashboard is active. + The lists are restricted to elements from the event selected in the previous step + (see :class:`NewEventWizardPrepareImportForm`). + """ import_categories = forms.ModelMultipleChoiceField( queryset=AKCategory.objects.all(), widget=forms.CheckboxSelectMultiple, @@ -60,6 +92,7 @@ class NewEventWizardImportForm(forms.Form): required=False, ) + # pylint: disable=too-many-arguments def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=ErrorList, label_suffix=None, empty_permitted=False, field_order=None, use_required_attribute=None, renderer=None): @@ -70,10 +103,12 @@ class NewEventWizardImportForm(forms.Form): self.fields["import_requirements"].queryset = self.fields["import_requirements"].queryset.filter( event=self.initial["import_event"]) + # pylint: disable=import-outside-toplevel + # Local imports used to prevent cyclic imports and to only import when AKDashboard is available from django.apps import apps if apps.is_installed("AKDashboard"): + # If AKDashboard is active, allow to copy dashboard buttons, too from AKDashboard.models import DashboardButton - self.fields["import_buttons"] = forms.ModelMultipleChoiceField( queryset=DashboardButton.objects.filter(event=self.initial["import_event"]), widget=forms.CheckboxSelectMultiple, @@ -83,20 +118,37 @@ class NewEventWizardImportForm(forms.Form): class NewEventWizardActivateForm(forms.ModelForm): + """ + Wizard form to activate the newly created event + """ class Meta: fields = ["active"] model = Event class AdminIntermediateForm(forms.Form): - pass + """ + Base form for admin intermediate views (forms used there should inherit from this, + by default, the form is empty since it is only needed for the confirmation button) + """ class AdminIntermediateActionForm(AdminIntermediateForm): + """ + Form for Admin Action Confirmation views -- has a pks field needed to handle the serialization/deserialization of + the IDs of the entities the user selected for the admin action to be performed on + """ pks = forms.CharField(widget=forms.HiddenInput) class SlideExportForm(AdminIntermediateForm): + """ + Form to control the slides generated from the AK list of an event + + The user can select how many upcoming AKs are displayed at the footer to inform people that it is their turn soon, + whether the AK list should be restricted to those AKs that where marked for presentation, and whether ther should + be a symbol and empty space to take notes on for wishes + """ num_next = forms.IntegerField( min_value=0, max_value=6, @@ -121,6 +173,9 @@ class SlideExportForm(AdminIntermediateForm): class DefaultSlotEditorForm(AdminIntermediateForm): + """ + Form for default slot editor + """ availabilities = forms.CharField( label=_('Default Slots'), help_text=_( @@ -133,6 +188,12 @@ class DefaultSlotEditorForm(AdminIntermediateForm): class RoomBatchCreationForm(AdminIntermediateForm): + """ + Form for room batch creation + + Allows to input a list of room details and choose whether default availabilities should be generated for these + rooms. Will check that the input follows a CSV format with at least a name column present. + """ rooms = forms.CharField( label=_('New rooms'), help_text=_('Enter room details in CSV format. Required colum is "name", optional colums are "location", ' @@ -147,6 +208,13 @@ class RoomBatchCreationForm(AdminIntermediateForm): ) def clean_rooms(self): + """ + Validate and transform the input for the rooms textfield + Treat the input as CSV and turn it into a dict containing the relevant information. + + :return: a dict containing the raw room information + :rtype: dict[str, str] + """ rooms_raw_text = self.cleaned_data["rooms"] rooms_raw_dict = csv.DictReader(io.StringIO(rooms_raw_text), delimiter=";") @@ -157,6 +225,10 @@ class RoomBatchCreationForm(AdminIntermediateForm): class RoomForm(forms.ModelForm): + """ + Room (creation) form (basic), will be extended for handling of availabilities + (see :class:`RoomFormWithAvailabilities`) and also for creating hybrid rooms in AKOnline (if active) + """ class Meta: model = Room fields = ['name', @@ -167,6 +239,9 @@ class RoomForm(forms.ModelForm): class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm): + """ + Room (update) form including handling of availabilities, extends :class:`RoomForm` + """ class Meta: model = Room fields = ['name', @@ -182,7 +257,7 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm): def __init__(self, *args, **kwargs): # Init availability mixin - kwargs['initial'] = dict() + kwargs['initial'] = {} super().__init__(*args, **kwargs) self.initial = {**self.initial, **kwargs['initial']} # Filter possible values for m2m when event is specified diff --git a/AKModel/locale/de_DE/LC_MESSAGES/django.po b/AKModel/locale/de_DE/LC_MESSAGES/django.po index f1f5756b..9041cdcb 100644 --- a/AKModel/locale/de_DE/LC_MESSAGES/django.po +++ b/AKModel/locale/de_DE/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-15 20:19+0200\n" +"POT-Creation-Date: 2023-08-13 11:20+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -11,7 +11,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: AKModel/admin.py:65 AKModel/admin.py:68 +#: AKModel/admin.py:86 AKModel/admin.py:96 #: AKModel/templates/admin/AKModel/event_wizard/activate.html:32 #: AKModel/templates/admin/AKModel/event_wizard/created_prepare_import.html:48 #: AKModel/templates/admin/AKModel/event_wizard/finish.html:21 @@ -21,63 +21,63 @@ msgstr "" msgid "Status" msgstr "Status" -#: AKModel/admin.py:70 +#: AKModel/admin.py:98 msgid "Toggle plan visibility" msgstr "Plansichtbarkeit ändern" -#: AKModel/admin.py:74 AKModel/admin.py:85 AKModel/views/manage.py:105 +#: AKModel/admin.py:110 AKModel/admin.py:121 AKModel/views/manage.py:106 msgid "Publish plan" msgstr "Plan veröffentlichen" -#: AKModel/admin.py:77 AKModel/admin.py:90 AKModel/views/manage.py:115 +#: AKModel/admin.py:113 AKModel/admin.py:129 AKModel/views/manage.py:116 msgid "Unpublish plan" msgstr "Plan verbergen" -#: AKModel/admin.py:153 +#: AKModel/admin.py:208 msgid "Wish" msgstr "AK-Wunsch" -#: AKModel/admin.py:159 +#: AKModel/admin.py:214 msgid "Is wish" msgstr "Ist ein Wunsch" -#: AKModel/admin.py:160 +#: AKModel/admin.py:215 msgid "Is not a wish" msgstr "Ist kein Wunsch" -#: AKModel/admin.py:204 +#: AKModel/admin.py:274 msgid "Export to wiki syntax" msgstr "In Wiki-Syntax exportieren" -#: AKModel/admin.py:213 +#: AKModel/admin.py:291 msgid "Cannot export AKs from more than one event at the same time." msgstr "Kann nicht AKs von mehreren Events zur selben Zeit exportieren." -#: AKModel/admin.py:228 AKModel/views/ak.py:80 +#: AKModel/admin.py:306 AKModel/views/ak.py:96 msgid "Reset interest in AKs" msgstr "Interesse an AKs zurücksetzen" -#: AKModel/admin.py:233 AKModel/views/ak.py:90 +#: AKModel/admin.py:316 AKModel/views/ak.py:111 msgid "Reset AKs' interest counters" msgstr "Interessenszähler der AKs zurücksetzen" -#: AKModel/admin.py:309 AKModel/admin.py:316 +#: AKModel/admin.py:415 AKModel/admin.py:429 msgid "AK Details" msgstr "AK-Details" -#: AKModel/admin.py:368 AKModel/views/manage.py:75 +#: AKModel/admin.py:491 AKModel/views/manage.py:76 msgid "Mark Constraint Violations as manually resolved" msgstr "Markiere Constraintverletzungen als manuell behoben" -#: AKModel/admin.py:373 AKModel/views/manage.py:85 +#: AKModel/admin.py:500 AKModel/views/manage.py:86 msgid "Set Constraint Violations to level \"violation\"" msgstr "Constraintverletzungen auf Level \"Violation\" setzen" -#: AKModel/admin.py:378 AKModel/views/manage.py:95 +#: AKModel/admin.py:509 AKModel/views/manage.py:96 msgid "Set Constraint Violations to level \"warning\"" msgstr "Constraintverletzungen auf Level \"Warning\" setzen" -#: AKModel/availability/forms.py:21 AKModel/availability/models.py:248 +#: AKModel/availability/forms.py:21 AKModel/availability/models.py:251 msgid "Availability" msgstr "Verfügbarkeit" @@ -98,113 +98,113 @@ msgstr "Die eingetragenen Verfügbarkeit haben nicht das notwendige Format." msgid "The submitted availability contains an invalid date." msgstr "Die eingegebene Verfügbarkeit enthält ein ungültiges Datum." -#: AKModel/availability/forms.py:124 AKModel/availability/forms.py:134 +#: AKModel/availability/forms.py:122 AKModel/availability/forms.py:132 msgid "Please fill in your availabilities!" msgstr "Bitte Verfügbarkeiten eintragen!" -#: AKModel/availability/models.py:38 AKModel/models.py:57 AKModel/models.py:129 -#: AKModel/models.py:184 AKModel/models.py:203 AKModel/models.py:224 -#: AKModel/models.py:277 AKModel/models.py:354 AKModel/models.py:387 -#: AKModel/models.py:458 AKModel/models.py:499 AKModel/models.py:664 +#: AKModel/availability/models.py:40 AKModel/models.py:58 AKModel/models.py:135 +#: AKModel/models.py:190 AKModel/models.py:209 AKModel/models.py:230 +#: AKModel/models.py:284 AKModel/models.py:366 AKModel/models.py:399 +#: AKModel/models.py:471 AKModel/models.py:512 AKModel/models.py:677 msgid "Event" msgstr "Event" -#: AKModel/availability/models.py:39 AKModel/models.py:130 -#: AKModel/models.py:185 AKModel/models.py:204 AKModel/models.py:225 -#: AKModel/models.py:278 AKModel/models.py:355 AKModel/models.py:388 -#: AKModel/models.py:459 AKModel/models.py:500 AKModel/models.py:665 +#: AKModel/availability/models.py:41 AKModel/models.py:136 +#: AKModel/models.py:191 AKModel/models.py:210 AKModel/models.py:231 +#: AKModel/models.py:285 AKModel/models.py:367 AKModel/models.py:400 +#: AKModel/models.py:472 AKModel/models.py:513 AKModel/models.py:678 msgid "Associated event" msgstr "Zugehöriges Event" -#: AKModel/availability/models.py:47 +#: AKModel/availability/models.py:49 msgid "Person" msgstr "Person" -#: AKModel/availability/models.py:48 +#: AKModel/availability/models.py:50 msgid "Person whose availability this is" msgstr "Person deren Verfügbarkeit hier abgebildet wird" -#: AKModel/availability/models.py:56 AKModel/models.py:358 -#: AKModel/models.py:377 AKModel/models.py:508 +#: AKModel/availability/models.py:58 AKModel/models.py:370 +#: AKModel/models.py:389 AKModel/models.py:521 msgid "Room" msgstr "Raum" -#: AKModel/availability/models.py:57 +#: AKModel/availability/models.py:59 msgid "Room whose availability this is" msgstr "Raum dessen Verfügbarkeit hier abgebildet wird" -#: AKModel/availability/models.py:65 AKModel/models.py:286 -#: AKModel/models.py:376 AKModel/models.py:453 +#: AKModel/availability/models.py:67 AKModel/models.py:293 +#: AKModel/models.py:388 AKModel/models.py:466 msgid "AK" msgstr "AK" -#: AKModel/availability/models.py:66 +#: AKModel/availability/models.py:68 msgid "AK whose availability this is" msgstr "Verfügbarkeiten" -#: AKModel/availability/models.py:74 AKModel/models.py:188 -#: AKModel/models.py:514 +#: AKModel/availability/models.py:76 AKModel/models.py:194 +#: AKModel/models.py:527 msgid "AK Category" msgstr "AK-Kategorie" -#: AKModel/availability/models.py:75 +#: AKModel/availability/models.py:77 msgid "AK Category whose availability this is" msgstr "AK-Kategorie, deren Verfügbarkeit hier abgebildet wird" -#: AKModel/availability/models.py:249 +#: AKModel/availability/models.py:252 msgid "Availabilities" msgstr "Verfügbarkeiten" -#: AKModel/forms.py:43 +#: AKModel/forms.py:65 msgid "Copy ak requirements and ak categories of existing event" msgstr "AK-Anforderungen und AK-Kategorien eines existierenden Events kopieren" -#: AKModel/forms.py:44 +#: AKModel/forms.py:66 msgid "You can choose what to copy in the next step" msgstr "" "Im nächsten Schritt kann ausgewählt werden, was genau kopiert werden soll" -#: AKModel/forms.py:52 +#: AKModel/forms.py:74 msgid "Copy ak categories" msgstr "AK-Kategorien kopieren" -#: AKModel/forms.py:59 +#: AKModel/forms.py:81 msgid "Copy ak requirements" msgstr "AK-Anforderungen kopieren" -#: AKModel/forms.py:80 +#: AKModel/forms.py:105 msgid "Copy dashboard buttons" msgstr "Dashboard-Buttons kopieren" -#: AKModel/forms.py:104 +#: AKModel/forms.py:129 msgid "# next AKs" msgstr "# nächste AKs" -#: AKModel/forms.py:105 +#: AKModel/forms.py:130 msgid "How many next AKs should be shown on a slide?" msgstr "Wie viele nächste AKs sollen auf einer Folie angezeigt werden?" -#: AKModel/forms.py:108 +#: AKModel/forms.py:133 msgid "Presentation only?" msgstr "Nur Vorstellung?" -#: AKModel/forms.py:110 AKModel/forms.py:117 +#: AKModel/forms.py:135 AKModel/forms.py:142 msgid "Yes" msgstr "Ja" -#: AKModel/forms.py:110 AKModel/forms.py:117 +#: AKModel/forms.py:135 AKModel/forms.py:142 msgid "No" msgstr "Nein" -#: AKModel/forms.py:112 +#: AKModel/forms.py:137 msgid "Restrict AKs to those that asked for chance to be presented?" msgstr "AKs auf solche, die um eine Vorstellung gebeten haben, einschränken?" -#: AKModel/forms.py:115 +#: AKModel/forms.py:140 msgid "Space for notes in wishes?" msgstr "Platz für Notizen bei den Wünschen?" -#: AKModel/forms.py:119 +#: AKModel/forms.py:144 msgid "" "Create symbols indicating space to note down owners and timeslots for " "wishes, e.g., to be filled out on a touch screen while presenting?" @@ -213,11 +213,11 @@ msgstr "" "fürWünsche markieren, z.B. um während der Präsentation auf einem Touchscreen " "ausgefüllt zu werden?" -#: AKModel/forms.py:125 AKModel/models.py:658 +#: AKModel/forms.py:150 AKModel/models.py:671 msgid "Default Slots" msgstr "Standardslots" -#: AKModel/forms.py:127 +#: AKModel/forms.py:152 msgid "" "Click and drag to add default slots, double-click to delete. Or use the " "start and end inputs to add entries to the calendar view." @@ -226,11 +226,11 @@ msgstr "" "Einträge zu löschen. Oder Start- und End-Eingabe verwenden, um der " "Kalenderansicht neue Einträge hinzuzufügen." -#: AKModel/forms.py:137 +#: AKModel/forms.py:162 msgid "New rooms" msgstr "Neue Räume" -#: AKModel/forms.py:138 +#: AKModel/forms.py:163 msgid "" "Enter room details in CSV format. Required colum is \"name\", optional " "colums are \"location\", \"capacity\", and \"url\" for online/hybrid rooms. " @@ -240,167 +240,167 @@ msgstr "" "Spalten sind \"location\", \"capacity\", und \"url\" for Online-/" "HybridräumeTrennzeichen: Semikolon" -#: AKModel/forms.py:144 +#: AKModel/forms.py:169 msgid "Default availabilities?" msgstr "Standardverfügbarkeiten?" -#: AKModel/forms.py:145 +#: AKModel/forms.py:170 msgid "Create default availabilities for all rooms?" msgstr "Standardverfügbarkeiten für alle Räume anlegen?" -#: AKModel/forms.py:154 +#: AKModel/forms.py:179 msgid "CSV must contain a name column" msgstr "CSV muss eine name-Spalte enthalten" -#: AKModel/metaviews/admin.py:97 AKModel/models.py:28 +#: AKModel/metaviews/admin.py:146 AKModel/models.py:29 msgid "Start" msgstr "Start" -#: AKModel/metaviews/admin.py:98 +#: AKModel/metaviews/admin.py:147 msgid "Settings" msgstr "Einstellungen" -#: AKModel/metaviews/admin.py:99 +#: AKModel/metaviews/admin.py:148 msgid "Event created, Prepare Import" msgstr "Event angelegt, Import vorbereiten" -#: AKModel/metaviews/admin.py:100 +#: AKModel/metaviews/admin.py:149 msgid "Import categories & requirements" msgstr "Kategorien & Anforderungen kopieren" -#: AKModel/metaviews/admin.py:101 +#: AKModel/metaviews/admin.py:150 msgid "Activate?" msgstr "Aktivieren?" -#: AKModel/metaviews/admin.py:102 +#: AKModel/metaviews/admin.py:151 #: AKModel/templates/admin/AKModel/event_wizard/activate.html:27 msgid "Finish" msgstr "Abschluss" -#: AKModel/models.py:19 AKModel/models.py:176 AKModel/models.py:200 -#: AKModel/models.py:222 AKModel/models.py:240 AKModel/models.py:346 +#: AKModel/models.py:20 AKModel/models.py:182 AKModel/models.py:206 +#: AKModel/models.py:228 AKModel/models.py:246 AKModel/models.py:358 msgid "Name" msgstr "Name" -#: AKModel/models.py:20 +#: AKModel/models.py:21 msgid "Name or iteration of the event" msgstr "Name oder Iteration des Events" -#: AKModel/models.py:21 +#: AKModel/models.py:22 msgid "Short Form" msgstr "Kurzer Name" -#: AKModel/models.py:22 +#: AKModel/models.py:23 msgid "Short name of letters/numbers/dots/dashes/underscores used in URLs." msgstr "" "Kurzname bestehend aus Buchstaben, Nummern, Punkten und Unterstrichen zur " "Nutzung in URLs" -#: AKModel/models.py:24 +#: AKModel/models.py:25 msgid "Place" msgstr "Ort" -#: AKModel/models.py:25 +#: AKModel/models.py:26 msgid "City etc. the event takes place in" msgstr "Stadt o.ä. in der das Event stattfindet" -#: AKModel/models.py:27 +#: AKModel/models.py:28 msgid "Time Zone" msgstr "Zeitzone" -#: AKModel/models.py:27 +#: AKModel/models.py:28 msgid "Time Zone where this event takes place in" msgstr "Zeitzone in der das Event stattfindet" -#: AKModel/models.py:28 +#: AKModel/models.py:29 msgid "Time the event begins" msgstr "Zeit zu der das Event beginnt" -#: AKModel/models.py:29 +#: AKModel/models.py:30 msgid "End" msgstr "Ende" -#: AKModel/models.py:29 +#: AKModel/models.py:30 msgid "Time the event ends" msgstr "Zeit zu der das Event endet" -#: AKModel/models.py:30 +#: AKModel/models.py:31 msgid "Resolution Deadline" msgstr "Resolutionsdeadline" -#: AKModel/models.py:31 +#: AKModel/models.py:32 msgid "When should AKs with intention to submit a resolution be done?" msgstr "Wann sollen AKs mit Resolutionsabsicht stattgefunden haben?" -#: AKModel/models.py:33 +#: AKModel/models.py:34 msgid "Interest Window Start" msgstr "Beginn Interessensbekundung" -#: AKModel/models.py:34 +#: AKModel/models.py:35 msgid "Opening time for expression of interest." msgstr "Öffnungszeitpunkt für die Angabe von Interesse an AKs." -#: AKModel/models.py:35 +#: AKModel/models.py:36 msgid "Interest Window End" msgstr "Ende Interessensbekundung" -#: AKModel/models.py:36 +#: AKModel/models.py:37 msgid "Closing time for expression of interest." msgstr "Öffnungszeitpunkt für die Angabe von Interesse an AKs." -#: AKModel/models.py:38 +#: AKModel/models.py:39 msgid "Public event" msgstr "Öffentliches Event" -#: AKModel/models.py:39 +#: AKModel/models.py:40 msgid "Show this event on overview page." msgstr "Zeige dieses Event auf der Übersichtseite an" -#: AKModel/models.py:41 +#: AKModel/models.py:42 msgid "Active State" msgstr "Aktiver Status" -#: AKModel/models.py:41 +#: AKModel/models.py:42 msgid "Marks currently active events" msgstr "Markiert aktuell aktive Events" -#: AKModel/models.py:42 +#: AKModel/models.py:43 msgid "Plan Hidden" msgstr "Plan verborgen" -#: AKModel/models.py:42 +#: AKModel/models.py:43 msgid "Hides plan for non-staff users" msgstr "Verbirgt den Plan für Nutzer*innen ohne erweiterte Rechte" -#: AKModel/models.py:44 +#: AKModel/models.py:45 msgid "Plan published at" msgstr "Plan veröffentlicht am/um" -#: AKModel/models.py:45 +#: AKModel/models.py:46 msgid "Timestamp at which the plan was published" msgstr "Zeitpunkt, zu dem der Plan veröffentlicht wurde" -#: AKModel/models.py:47 +#: AKModel/models.py:48 msgid "Base URL" msgstr "URL-Prefix" -#: AKModel/models.py:47 +#: AKModel/models.py:48 msgid "Prefix for wiki link construction" msgstr "Prefix für die automatische Generierung von Wiki-Links" -#: AKModel/models.py:48 +#: AKModel/models.py:49 msgid "Wiki Export Template Name" msgstr "Wiki-Export Templatename" -#: AKModel/models.py:49 +#: AKModel/models.py:50 msgid "Default Slot Length" msgstr "Standardslotlänge" -#: AKModel/models.py:50 +#: AKModel/models.py:51 msgid "Default length in hours that is assumed for AKs in this event." msgstr "Standardlänge von Slots (in Stunden) für dieses Event" -#: AKModel/models.py:52 +#: AKModel/models.py:53 msgid "Contact email address" msgstr "E-Mail Kontaktadresse" @@ -412,75 +412,75 @@ msgstr "" "Eine Mailadresse die auf jeder Seite angezeigt wird und für alle Arten von " "Fragen genutzt werden kann" -#: AKModel/models.py:58 +#: AKModel/models.py:59 msgid "Events" msgstr "Events" -#: AKModel/models.py:124 +#: AKModel/models.py:130 msgid "Nickname" msgstr "Spitzname" -#: AKModel/models.py:124 +#: AKModel/models.py:130 msgid "Name to identify an AK owner by" msgstr "Name, durch den eine AK-Leitung identifiziert wird" -#: AKModel/models.py:125 +#: AKModel/models.py:131 msgid "Slug" msgstr "Slug" -#: AKModel/models.py:125 +#: AKModel/models.py:131 msgid "Slug for URL generation" msgstr "Slug für URL-Generierung" -#: AKModel/models.py:126 +#: AKModel/models.py:132 msgid "Institution" msgstr "Instutution" -#: AKModel/models.py:126 +#: AKModel/models.py:132 msgid "Uni etc." msgstr "Universität o.ä." -#: AKModel/models.py:127 AKModel/models.py:249 +#: AKModel/models.py:133 AKModel/models.py:255 msgid "Web Link" msgstr "Internet Link" -#: AKModel/models.py:127 +#: AKModel/models.py:133 msgid "Link to Homepage" msgstr "Link zu Homepage oder Webseite" -#: AKModel/models.py:133 AKModel/models.py:507 +#: AKModel/models.py:139 AKModel/models.py:520 msgid "AK Owner" msgstr "AK-Leitung" -#: AKModel/models.py:134 +#: AKModel/models.py:140 msgid "AK Owners" msgstr "AK-Leitungen" -#: AKModel/models.py:176 +#: AKModel/models.py:182 msgid "Name of the AK Category" msgstr "Name der AK-Kategorie" -#: AKModel/models.py:177 AKModel/models.py:201 +#: AKModel/models.py:183 AKModel/models.py:207 msgid "Color" msgstr "Farbe" -#: AKModel/models.py:177 AKModel/models.py:201 +#: AKModel/models.py:183 AKModel/models.py:207 msgid "Color for displaying" msgstr "Farbe für die Anzeige" -#: AKModel/models.py:178 AKModel/models.py:243 +#: AKModel/models.py:184 AKModel/models.py:249 msgid "Description" msgstr "Beschreibung" -#: AKModel/models.py:179 +#: AKModel/models.py:185 msgid "Short description of this AK Category" msgstr "Beschreibung der AK-Kategorie" -#: AKModel/models.py:180 +#: AKModel/models.py:186 msgid "Present by default" msgstr "Defaultmäßig präsentieren" -#: AKModel/models.py:182 +#: AKModel/models.py:187 msgid "" "Present AKs of this category by default if AK owner did not specify whether " "this AK should be presented?" @@ -488,132 +488,132 @@ msgstr "" "AKs dieser Kategorie standardmäßig vorstellen, wenn die Leitungen das für " "ihren AK nicht explizit spezifiziert haben?" -#: AKModel/models.py:189 +#: AKModel/models.py:195 msgid "AK Categories" msgstr "AK-Kategorien" -#: AKModel/models.py:200 +#: AKModel/models.py:206 msgid "Name of the AK Track" msgstr "Name des AK-Tracks" -#: AKModel/models.py:207 +#: AKModel/models.py:213 msgid "AK Track" msgstr "AK-Track" -#: AKModel/models.py:208 +#: AKModel/models.py:214 msgid "AK Tracks" msgstr "AK-Tracks" -#: AKModel/models.py:222 +#: AKModel/models.py:228 msgid "Name of the Requirement" msgstr "Name der Anforderung" -#: AKModel/models.py:228 AKModel/models.py:511 +#: AKModel/models.py:234 AKModel/models.py:524 msgid "AK Requirement" msgstr "AK-Anforderung" -#: AKModel/models.py:229 +#: AKModel/models.py:235 msgid "AK Requirements" msgstr "AK-Anforderungen" -#: AKModel/models.py:240 +#: AKModel/models.py:246 msgid "Name of the AK" msgstr "Name des AKs" -#: AKModel/models.py:241 +#: AKModel/models.py:247 msgid "Short Name" msgstr "Kurzer Name" -#: AKModel/models.py:242 +#: AKModel/models.py:248 msgid "Name displayed in the schedule" msgstr "Name zur Anzeige im AK-Plan" -#: AKModel/models.py:243 +#: AKModel/models.py:249 msgid "Description of the AK" msgstr "Beschreibung des AKs" -#: AKModel/models.py:245 +#: AKModel/models.py:251 msgid "Owners" msgstr "Leitungen" -#: AKModel/models.py:246 +#: AKModel/models.py:252 msgid "Those organizing the AK" msgstr "Menschen, die den AK organisieren und halten" -#: AKModel/models.py:249 +#: AKModel/models.py:255 msgid "Link to wiki page" msgstr "Link zur Wiki Seite" -#: AKModel/models.py:250 +#: AKModel/models.py:256 msgid "Protocol Link" msgstr "Protokolllink" -#: AKModel/models.py:250 +#: AKModel/models.py:256 msgid "Link to protocol" msgstr "Link zum Protokoll" -#: AKModel/models.py:252 +#: AKModel/models.py:258 msgid "Category" msgstr "Kategorie" -#: AKModel/models.py:253 +#: AKModel/models.py:259 msgid "Category of the AK" msgstr "Kategorie des AKs" -#: AKModel/models.py:254 +#: AKModel/models.py:260 msgid "Track" msgstr "Track" -#: AKModel/models.py:255 +#: AKModel/models.py:261 msgid "Track the AK belongs to" msgstr "Track zu dem der AK gehört" -#: AKModel/models.py:257 +#: AKModel/models.py:263 msgid "Resolution Intention" msgstr "Resolutionsabsicht" -#: AKModel/models.py:258 +#: AKModel/models.py:264 msgid "Intends to submit a resolution" msgstr "Beabsichtigt eine Resolution einzureichen" -#: AKModel/models.py:259 +#: AKModel/models.py:265 msgid "Present this AK" msgstr "AK präsentieren" -#: AKModel/models.py:260 +#: AKModel/models.py:266 msgid "Present results of this AK" msgstr "Die Ergebnisse dieses AKs vorstellen" -#: AKModel/models.py:262 AKModel/views/status.py:136 +#: AKModel/models.py:268 AKModel/views/status.py:140 msgid "Requirements" msgstr "Anforderungen" -#: AKModel/models.py:263 +#: AKModel/models.py:269 msgid "AK's Requirements" msgstr "Anforderungen des AKs" -#: AKModel/models.py:265 +#: AKModel/models.py:271 msgid "Conflicting AKs" msgstr "AK-Konflikte" -#: AKModel/models.py:266 +#: AKModel/models.py:272 msgid "AKs that conflict and thus must not take place at the same time" msgstr "" "AKs, die Konflikte haben und deshalb nicht gleichzeitig stattfinden dürfen" -#: AKModel/models.py:267 +#: AKModel/models.py:273 msgid "Prerequisite AKs" msgstr "Vorausgesetzte AKs" -#: AKModel/models.py:268 +#: AKModel/models.py:274 msgid "AKs that should precede this AK in the schedule" msgstr "AKs die im AK-Plan vor diesem AK stattfinden müssen" -#: AKModel/models.py:270 +#: AKModel/models.py:276 msgid "Organizational Notes" msgstr "Notizen zur Organisation" -#: AKModel/models.py:271 +#: AKModel/models.py:277 msgid "" "Notes to organizers. These are public. For private notes, please use the " "button for private messages on the detail page of this AK (after creation/" @@ -623,291 +623,291 @@ msgstr "" "Anmerkungen bitte den Button für Direktnachrichten verwenden (nach dem " "Anlegen/Bearbeiten)." -#: AKModel/models.py:273 +#: AKModel/models.py:280 msgid "Interest" msgstr "Interesse" -#: AKModel/models.py:273 +#: AKModel/models.py:280 msgid "Expected number of people" msgstr "Erwartete Personenzahl" -#: AKModel/models.py:274 +#: AKModel/models.py:281 msgid "Interest Counter" msgstr "Interessenszähler" -#: AKModel/models.py:275 +#: AKModel/models.py:282 msgid "People who have indicated interest online" msgstr "Anzahl Personen, die online Interesse bekundet haben" -#: AKModel/models.py:280 +#: AKModel/models.py:287 msgid "Export?" msgstr "Export?" -#: AKModel/models.py:281 +#: AKModel/models.py:288 msgid "Include AK in wiki export?" msgstr "AK bei Wiki-Export berücksichtigen?" -#: AKModel/models.py:287 AKModel/models.py:502 +#: AKModel/models.py:294 AKModel/models.py:515 #: AKModel/templates/admin/AKModel/status/event_aks.html:10 -#: AKModel/views/manage.py:55 AKModel/views/status.py:74 +#: AKModel/views/manage.py:56 AKModel/views/status.py:76 msgid "AKs" msgstr "AKs" -#: AKModel/models.py:346 +#: AKModel/models.py:358 msgid "Name or number of the room" msgstr "Name oder Nummer des Raums" -#: AKModel/models.py:347 +#: AKModel/models.py:359 msgid "Location" msgstr "Ort" -#: AKModel/models.py:348 +#: AKModel/models.py:360 msgid "Name or number of the location" msgstr "Name oder Nummer des Ortes" -#: AKModel/models.py:349 +#: AKModel/models.py:361 msgid "Capacity" msgstr "Kapazität" -#: AKModel/models.py:350 +#: AKModel/models.py:362 msgid "Maximum number of people (-1 for unlimited)." msgstr "Maximale Personenzahl (-1 wenn unbeschränkt)." -#: AKModel/models.py:351 +#: AKModel/models.py:363 msgid "Properties" msgstr "Eigenschaften" -#: AKModel/models.py:352 +#: AKModel/models.py:364 msgid "AK requirements fulfilled by the room" msgstr "AK-Anforderungen, die dieser Raum erfüllt" -#: AKModel/models.py:359 AKModel/views/status.py:44 +#: AKModel/models.py:371 AKModel/views/status.py:45 msgid "Rooms" msgstr "Räume" -#: AKModel/models.py:376 +#: AKModel/models.py:388 msgid "AK being mapped" msgstr "AK, der zugeordnet wird" -#: AKModel/models.py:378 +#: AKModel/models.py:390 msgid "Room the AK will take place in" msgstr "Raum in dem der AK stattfindet" -#: AKModel/models.py:379 AKModel/models.py:661 +#: AKModel/models.py:391 AKModel/models.py:674 msgid "Slot Begin" msgstr "Beginn des Slots" -#: AKModel/models.py:379 AKModel/models.py:661 +#: AKModel/models.py:391 AKModel/models.py:674 msgid "Time and date the slot begins" msgstr "Zeit und Datum zu der der AK beginnt" -#: AKModel/models.py:381 +#: AKModel/models.py:393 msgid "Duration" msgstr "Dauer" -#: AKModel/models.py:382 +#: AKModel/models.py:394 msgid "Length in hours" msgstr "Länge in Stunden" -#: AKModel/models.py:384 +#: AKModel/models.py:396 msgid "Scheduling fixed" msgstr "Planung fix" -#: AKModel/models.py:385 +#: AKModel/models.py:397 msgid "Length and time of this AK should not be changed" msgstr "Dauer und Zeit dieses AKs sollten nicht verändert werden" -#: AKModel/models.py:390 +#: AKModel/models.py:402 msgid "Last update" msgstr "Letzte Aktualisierung" -#: AKModel/models.py:393 +#: AKModel/models.py:405 msgid "AK Slot" msgstr "AK-Slot" -#: AKModel/models.py:394 AKModel/models.py:504 +#: AKModel/models.py:406 AKModel/models.py:517 msgid "AK Slots" msgstr "AK-Slot" -#: AKModel/models.py:416 AKModel/models.py:425 +#: AKModel/models.py:428 AKModel/models.py:437 msgid "Not scheduled yet" msgstr "Noch nicht geplant" -#: AKModel/models.py:454 +#: AKModel/models.py:467 msgid "AK this message belongs to" msgstr "AK zu dem die Nachricht gehört" -#: AKModel/models.py:455 +#: AKModel/models.py:468 msgid "Message text" msgstr "Nachrichtentext" -#: AKModel/models.py:456 +#: AKModel/models.py:469 msgid "Message to the organizers. This is not publicly visible." msgstr "" "Nachricht an die Organisator*innen. Diese ist nicht öffentlich sichtbar." -#: AKModel/models.py:462 +#: AKModel/models.py:475 msgid "AK Orga Message" msgstr "AK-Organachricht" -#: AKModel/models.py:463 +#: AKModel/models.py:476 msgid "AK Orga Messages" msgstr "AK-Organachrichten" -#: AKModel/models.py:472 +#: AKModel/models.py:485 msgid "Constraint Violation" msgstr "Constraintverletzung" -#: AKModel/models.py:473 AKModel/views/status.py:93 +#: AKModel/models.py:486 AKModel/views/status.py:95 msgid "Constraint Violations" msgstr "Constraintverletzungen" -#: AKModel/models.py:477 +#: AKModel/models.py:490 msgid "Owner has two parallel slots" msgstr "Leitung hat zwei Slots parallel" -#: AKModel/models.py:478 +#: AKModel/models.py:491 msgid "AK Slot was scheduled outside the AK's availabilities" msgstr "AK Slot wurde außerhalb der Verfügbarkeit des AKs platziert" -#: AKModel/models.py:479 +#: AKModel/models.py:492 msgid "Room has two AK slots scheduled at the same time" msgstr "Raum hat zwei AK Slots gleichzeitig" -#: AKModel/models.py:480 +#: AKModel/models.py:493 msgid "Room does not satisfy the requirement of the scheduled AK" msgstr "Room erfüllt die Anforderungen des platzierten AKs nicht" -#: AKModel/models.py:481 +#: AKModel/models.py:494 msgid "AK Slot is scheduled at the same time as an AK listed as a conflict" msgstr "" "AK Slot wurde wurde zur gleichen Zeit wie ein Konflikt des AKs platziert" -#: AKModel/models.py:482 +#: AKModel/models.py:495 msgid "AK Slot is scheduled before an AK listed as a prerequisite" msgstr "AK Slot wurde vor einem als Voraussetzung gelisteten AK platziert" -#: AKModel/models.py:484 +#: AKModel/models.py:497 msgid "" "AK Slot for AK with intention to submit a resolution is scheduled after " "resolution deadline" msgstr "" "AK Slot eines AKs mit Resoabsicht wurde nach der Resodeadline platziert" -#: AKModel/models.py:485 +#: AKModel/models.py:498 msgid "AK Slot in a category is outside that categories availabilities" msgstr "AK Slot wurde außerhalb der Verfügbarkeiten seiner Kategorie" -#: AKModel/models.py:486 +#: AKModel/models.py:499 msgid "Two AK Slots for the same AK scheduled at the same time" msgstr "Zwei AK Slots eines AKs wurden zur selben Zeit platziert" -#: AKModel/models.py:487 +#: AKModel/models.py:500 msgid "Room does not have enough space for interest in scheduled AK Slot" msgstr "Room hat nicht genug Platz für das Interesse am geplanten AK-Slot" -#: AKModel/models.py:488 +#: AKModel/models.py:501 msgid "AK Slot is scheduled outside the event's availabilities" msgstr "AK Slot wurde außerhalb der Verfügbarkeit des Events platziert" -#: AKModel/models.py:491 +#: AKModel/models.py:504 msgid "Warning" msgstr "Warnung" -#: AKModel/models.py:492 +#: AKModel/models.py:505 msgid "Violation" msgstr "Verletzung" -#: AKModel/models.py:494 +#: AKModel/models.py:507 msgid "Type" msgstr "Art" -#: AKModel/models.py:495 +#: AKModel/models.py:508 msgid "Type of violation, i.e. what kind of constraint was violated" msgstr "Art der Verletzung, gibt an welche Art Constraint verletzt wurde" -#: AKModel/models.py:496 +#: AKModel/models.py:509 msgid "Level" msgstr "Level" -#: AKModel/models.py:497 +#: AKModel/models.py:510 msgid "Severity level of the violation" msgstr "Schweregrad der Verletzung" -#: AKModel/models.py:503 +#: AKModel/models.py:516 msgid "AK(s) belonging to this constraint" msgstr "AK(s), die zu diesem Constraint gehören" -#: AKModel/models.py:505 +#: AKModel/models.py:518 msgid "AK Slot(s) belonging to this constraint" msgstr "AK Slot(s), die zu diesem Constraint gehören" -#: AKModel/models.py:507 +#: AKModel/models.py:520 msgid "AK Owner belonging to this constraint" msgstr "AK Leitung(en), die zu diesem Constraint gehören" -#: AKModel/models.py:509 +#: AKModel/models.py:522 msgid "Room belonging to this constraint" msgstr "Raum, der zu diesem Constraint gehört" -#: AKModel/models.py:512 +#: AKModel/models.py:525 msgid "AK Requirement belonging to this constraint" msgstr "AK Anforderung, die zu diesem Constraint gehört" -#: AKModel/models.py:514 +#: AKModel/models.py:527 msgid "AK Category belonging to this constraint" msgstr "AK Kategorie, di zu diesem Constraint gehört" -#: AKModel/models.py:516 +#: AKModel/models.py:529 msgid "Comment" msgstr "Kommentar" -#: AKModel/models.py:516 +#: AKModel/models.py:529 msgid "Comment or further details for this violation" msgstr "Kommentar oder weitere Details zu dieser Vereletzung" -#: AKModel/models.py:519 +#: AKModel/models.py:532 msgid "Timestamp" msgstr "Timestamp" -#: AKModel/models.py:519 +#: AKModel/models.py:532 msgid "Time of creation" msgstr "Zeitpunkt der ERstellung" -#: AKModel/models.py:520 +#: AKModel/models.py:533 msgid "Manually Resolved" msgstr "Manuell behoben" -#: AKModel/models.py:521 +#: AKModel/models.py:534 msgid "Mark this violation manually as resolved" msgstr "Markiere diese Verletzung manuell als behoben" -#: AKModel/models.py:548 +#: AKModel/models.py:561 #: AKModel/templates/admin/AKModel/requirements_overview.html:27 msgid "Details" msgstr "Details" -#: AKModel/models.py:657 +#: AKModel/models.py:670 msgid "Default Slot" msgstr "Standardslot" -#: AKModel/models.py:662 +#: AKModel/models.py:675 msgid "Slot End" msgstr "Ende des Slots" -#: AKModel/models.py:662 +#: AKModel/models.py:675 msgid "Time and date the slot ends" msgstr "Zeit und Datum zu der der Slot endet" -#: AKModel/models.py:667 +#: AKModel/models.py:680 msgid "Primary categories" msgstr "Primäre Kategorien" -#: AKModel/models.py:668 +#: AKModel/models.py:681 msgid "Categories that should be assigned to this slot primarily" msgstr "Kategorieren, die diesem Slot primär zugewiesen werden sollen" -#: AKModel/site.py:10 +#: AKModel/site.py:11 msgid "Administration" msgstr "Verwaltung" @@ -1015,7 +1015,7 @@ msgid "No AKs with this requirement" msgstr "Kein AK mit dieser Anforderung" #: AKModel/templates/admin/AKModel/requirements_overview.html:45 -#: AKModel/views/status.py:150 +#: AKModel/views/status.py:156 msgid "Add Requirement" msgstr "Anforderung hinzufügen" @@ -1068,7 +1068,7 @@ msgstr "Bisher keine Räume" msgid "Active Events" msgstr "Aktive Events" -#: AKModel/templates/admin/ak_index.html:16 AKModel/views/status.py:85 +#: AKModel/templates/admin/ak_index.html:16 AKModel/views/status.py:87 msgid "Scheduling" msgstr "Scheduling" @@ -1101,160 +1101,160 @@ msgstr "Login" msgid "Register" msgstr "Registrieren" -#: AKModel/views/ak.py:14 +#: AKModel/views/ak.py:17 msgid "Requirements for Event" msgstr "Anforderungen für das Event" -#: AKModel/views/ak.py:28 +#: AKModel/views/ak.py:34 msgid "AK CSV Export" msgstr "AK-CSV-Export" -#: AKModel/views/ak.py:42 +#: AKModel/views/ak.py:48 msgid "AK Wiki Export" msgstr "AK-Wiki-Export" -#: AKModel/views/ak.py:53 AKModel/views/manage.py:41 +#: AKModel/views/ak.py:59 AKModel/views/manage.py:42 msgid "Wishes" msgstr "Wünsche" -#: AKModel/views/ak.py:60 +#: AKModel/views/ak.py:71 msgid "Delete AK Orga Messages" msgstr "AK-Organachrichten löschen" -#: AKModel/views/ak.py:75 +#: AKModel/views/ak.py:86 msgid "AK Orga Messages successfully deleted" msgstr "AK-Organachrichten erfolgreich gelöscht" -#: AKModel/views/ak.py:82 +#: AKModel/views/ak.py:98 msgid "Interest of the following AKs will be set to not filled (-1):" msgstr "Interesse an den folgenden AKs wird auf nicht ausgefüllt (-1) gesetzt:" -#: AKModel/views/ak.py:83 +#: AKModel/views/ak.py:99 msgid "Reset of interest in AKs successful." msgstr "Interesse an AKs erfolgreich zurückgesetzt." -#: AKModel/views/ak.py:92 +#: AKModel/views/ak.py:113 msgid "Interest counter of the following AKs will be set to 0:" msgstr "Interessensbekundungszähler der folgenden AKs wird auf 0 gesetzt:" -#: AKModel/views/ak.py:93 +#: AKModel/views/ak.py:114 msgid "AKs' interest counters set back to 0." msgstr "Interessenszähler der AKs zurückgesetzt" -#: AKModel/views/event_wizard.py:69 +#: AKModel/views/event_wizard.py:70 #, python-format msgid "Copied '%(obj)s'" msgstr "'%(obj)s' kopiert" -#: AKModel/views/event_wizard.py:72 +#: AKModel/views/event_wizard.py:73 #, python-format msgid "Could not copy '%(obj)s' (%(error)s)" msgstr "'%(obj)s' konnte nicht kopiert werden (%(error)s)" -#: AKModel/views/manage.py:25 AKModel/views/status.py:125 +#: AKModel/views/manage.py:25 AKModel/views/status.py:129 msgid "Export AK Slides" msgstr "AK-Folien exportieren" -#: AKModel/views/manage.py:36 +#: AKModel/views/manage.py:37 msgid "Symbols" msgstr "Symbole" -#: AKModel/views/manage.py:37 +#: AKModel/views/manage.py:38 msgid "Who?" msgstr "Wer?" -#: AKModel/views/manage.py:38 +#: AKModel/views/manage.py:39 msgid "Duration(s)" msgstr "Dauer(n)" -#: AKModel/views/manage.py:39 +#: AKModel/views/manage.py:40 msgid "Reso intention?" msgstr "Resolutionsabsicht?" -#: AKModel/views/manage.py:40 +#: AKModel/views/manage.py:41 msgid "Category (for Wishes)" msgstr "Kategorie (für Wünsche)" -#: AKModel/views/manage.py:77 +#: AKModel/views/manage.py:78 msgid "The following Constraint Violations will be marked as manually resolved" msgstr "" "Die folgenden Constraintverletzungen werden als manuell behoben markiert." -#: AKModel/views/manage.py:78 +#: AKModel/views/manage.py:79 msgid "Constraint Violations marked as resolved" msgstr "Constraintverletzungen als manuell behoben markiert" -#: AKModel/views/manage.py:87 +#: AKModel/views/manage.py:88 msgid "The following Constraint Violations will be set to level 'violation'" msgstr "" "Die folgenden Constraintverletzungen werden auf das Level \"Violation\" " "gesetzt." -#: AKModel/views/manage.py:88 +#: AKModel/views/manage.py:89 msgid "Constraint Violations set to level 'violation'" msgstr "Constraintverletzungen auf Level \"Violation\" gesetzt" -#: AKModel/views/manage.py:97 +#: AKModel/views/manage.py:98 msgid "The following Constraint Violations will be set to level 'warning'" msgstr "" "Die folgenden Constraintverletzungen werden auf das Level 'warning' gesetzt." -#: AKModel/views/manage.py:98 +#: AKModel/views/manage.py:99 msgid "Constraint Violations set to level 'warning'" msgstr "Constraintverletzungen auf Level \"Warning\" gesetzt" -#: AKModel/views/manage.py:107 +#: AKModel/views/manage.py:108 msgid "Publish the plan(s) of:" msgstr "Den Plan/die Pläne veröffentlichen von:" -#: AKModel/views/manage.py:108 +#: AKModel/views/manage.py:109 msgid "Plan published" msgstr "Plan veröffentlicht" -#: AKModel/views/manage.py:117 +#: AKModel/views/manage.py:118 msgid "Unpublish the plan(s) of:" msgstr "Den Plan/die Pläne verbergen von:" -#: AKModel/views/manage.py:118 +#: AKModel/views/manage.py:119 msgid "Plan unpublished" msgstr "Plan verborgen" -#: AKModel/views/manage.py:127 AKModel/views/status.py:109 +#: AKModel/views/manage.py:128 AKModel/views/status.py:113 msgid "Edit Default Slots" msgstr "Standardslots bearbeiten" -#: AKModel/views/manage.py:164 +#: AKModel/views/manage.py:165 #, python-brace-format msgid "Could not update slot {id} since it does not belong to {event}" msgstr "" "Konnte Slot {id} nicht bearbeiten, da er nicht zum Event {event} gehört" -#: AKModel/views/manage.py:194 +#: AKModel/views/manage.py:195 #, python-brace-format msgid "Updated {u} slot(s). created {c} new slot(s) and deleted {d} slot(s)" msgstr "" "{u} Slot(s) aktualisiert, {c} Slot(s) hinzugefügt und {d} Slot(s) gelöscht" -#: AKModel/views/room.py:32 +#: AKModel/views/room.py:34 #, python-format msgid "Created Room '%(room)s'" msgstr "Raum '%(room)s' angelegt" -#: AKModel/views/room.py:38 AKModel/views/status.py:64 +#: AKModel/views/room.py:41 AKModel/views/status.py:66 msgid "Import Rooms from CSV" msgstr "Räume aus CSV importieren" -#: AKModel/views/room.py:73 +#: AKModel/views/room.py:77 #, python-brace-format msgid "Could not import room {name}: {e}" msgstr "Konnte Raum {name} nicht importieren: {e}" -#: AKModel/views/room.py:77 +#: AKModel/views/room.py:81 #, python-brace-format msgid "Imported {count} room(s)" msgstr "{count} Raum/Räume importiert" -#: AKModel/views/room.py:79 +#: AKModel/views/room.py:83 msgid "No rooms imported" msgstr "Keine Räume importiert" @@ -1270,35 +1270,35 @@ msgstr "Kategorien" msgid "Add category" msgstr "Kategorie hinzufügen" -#: AKModel/views/status.py:48 +#: AKModel/views/status.py:49 msgid "Add Room" msgstr "Raum hinzufügen" -#: AKModel/views/status.py:98 +#: AKModel/views/status.py:100 msgid "AKs requiring special attention" msgstr "AKs, die besondere Aufmerksamkeit benötigen" -#: AKModel/views/status.py:102 +#: AKModel/views/status.py:104 msgid "Enter Interest" msgstr "Interesse erfassen" -#: AKModel/views/status.py:113 +#: AKModel/views/status.py:117 msgid "Manage ak tracks" msgstr "AK-Tracks verwalten" -#: AKModel/views/status.py:117 +#: AKModel/views/status.py:121 msgid "Export AKs as CSV" msgstr "AKs als CSV exportieren" -#: AKModel/views/status.py:121 +#: AKModel/views/status.py:125 msgid "Export AKs for Wiki" msgstr "AKs im Wiki-Format exportieren" -#: AKModel/views/status.py:146 +#: AKModel/views/status.py:152 msgid "Show AKs for requirements" msgstr "Zu Anforderungen gehörige AKs anzeigen" -#: AKModel/views/status.py:157 +#: AKModel/views/status.py:163 msgid "Event Status" msgstr "Eventstatus" diff --git a/AKModel/management/commands/makemessages.py b/AKModel/management/commands/makemessages.py index d3e9149e..bd9f89e1 100644 --- a/AKModel/management/commands/makemessages.py +++ b/AKModel/management/commands/makemessages.py @@ -1,13 +1,15 @@ -""" -Ensure PO files are generated using forward slashes in the location comments on all operating systems -""" import os from django.core.management.commands.makemessages import Command as MakeMessagesCommand class Command(MakeMessagesCommand): + """ + Adapted version of the :class:`MakeMessagesCommand` + Ensure PO files are generated using forward slashes in the location comments on all operating systems + """ def find_files(self, root): + # Replace backward slashes with forward slashes if necessary in file list all_files = super().find_files(root) if os.sep != "\\": return all_files @@ -21,17 +23,19 @@ class Command(MakeMessagesCommand): return all_files def build_potfiles(self): + # Replace backward slashes with forward slashes if necessary in the files itself pot_files = super().build_potfiles() if os.sep != "\\": return pot_files for filename in pot_files: - lines = open(filename, "r", encoding="utf-8").readlines() - fixed_lines = [] - for line in lines: - if line.startswith("#: "): - line = line.replace("\\", "/") - fixed_lines.append(line) + with open(filename, "r", encoding="utf-8") as f: + lines = f.readlines() + fixed_lines = [] + for line in lines: + if line.startswith("#: "): + line = line.replace("\\", "/") + fixed_lines.append(line) with open(filename, "w", encoding="utf-8") as f: f.writelines(fixed_lines) diff --git a/AKModel/metaviews/__init__.py b/AKModel/metaviews/__init__.py index f5a3ee68..3956af08 100644 --- a/AKModel/metaviews/__init__.py +++ b/AKModel/metaviews/__init__.py @@ -1,3 +1,5 @@ from AKModel.metaviews.status import StatusManager +# create on instance of the :class:`AKModel.metaviews.status.StatusManager` +# that can then be accessed everywhere (singleton pattern) status_manager = StatusManager() diff --git a/AKModel/metaviews/admin.py b/AKModel/metaviews/admin.py index 9c6f9027..fb0ff606 100644 --- a/AKModel/metaviews/admin.py +++ b/AKModel/metaviews/admin.py @@ -13,36 +13,61 @@ from AKModel.models import Event class EventSlugMixin: """ Mixin to handle views with event slugs + + This will make the relevant event available as self.event in all kind types of requests + (generic GET and POST views, list views, dispatching, create views) """ + # pylint: disable=no-member event = None def _load_event(self): + """ + Perform the real loading of the event data (as specified by event_slug in the URL) into self.event + """ # Find event based on event slug if self.event is None: self.event = get_object_or_404(Event, slug=self.kwargs.get("event_slug", None)) def get(self, request, *args, **kwargs): + """ + Override GET request handling to perform loading event first + """ self._load_event() return super().get(request, *args, **kwargs) def post(self, request, *args, **kwargs): + """ + Override POST request handling to perform loading event first + """ self._load_event() return super().post(request, *args, **kwargs) def list(self, request, *args, **kwargs): + """ + Override list view request handling to perform loading event first + """ self._load_event() return super().list(request, *args, **kwargs) def create(self, request, *args, **kwargs): + """ + Override create view request handling to perform loading event first + """ self._load_event() return super().create(request, *args, **kwargs) def dispatch(self, request, *args, **kwargs): + """ + Override dispatch which is called in many generic views to perform loading event first + """ if self.event is None: self._load_event() return super().dispatch(request, *args, **kwargs) def get_context_data(self, *, object_list=None, **kwargs): + """ + Override `get_context_data` to make the event information available in the rendering context as `event`. too + """ context = super().get_context_data(object_list=object_list, **kwargs) # Add event to context (to make it accessible in templates) context["event"] = self.event @@ -55,15 +80,29 @@ class FilterByEventSlugMixin(EventSlugMixin): """ def get_queryset(self): - # Filter current queryset based on url event slug or return 404 if event slug is invalid + """ + Get adapted queryset: + Filter current queryset based on url event slug or return 404 if event slug is invalid + :return: Queryset + """ return super().get_queryset().filter(event=self.event) class AdminViewMixin: + """ + Mixin to provide context information needed in custom admin views + + Will either use default information for `site_url` and `title` or allows to set own values for that + """ + # pylint: disable=too-few-public-methods + site_url = '' title = '' def get_context_data(self, **kwargs): + """ + Extend context + """ extra = admin.site.each_context(self.request) extra.update(super().get_context_data(**kwargs)) @@ -76,10 +115,19 @@ class AdminViewMixin: class IntermediateAdminView(AdminViewMixin, FormView): + """ + Metaview: Handle typical "action but with preview and confirmation step before" workflow + """ template_name = "admin/AKModel/action_intermediate.html" form_class = AdminIntermediateForm def get_preview(self): + """ + Render a preview of the action to be performed. + Default is empty + :return: preview (html) + :rtype: str + """ return "" def get_context_data(self, **kwargs): @@ -90,7 +138,18 @@ class IntermediateAdminView(AdminViewMixin, FormView): class WizardViewMixin: + """ + Mixin to create wizard-like views. + This visualizes the progress of the user in the creation process and provides the interlinking to the next step + + In the current implementation, the steps of the wizard are hardcoded here, + hence this mixin can only be used for the event creation wizard + """ + # pylint: disable=too-few-public-methods def get_context_data(self, **kwargs): + """ + Extend context + """ context = super().get_context_data(**kwargs) context["wizard_step"] = self.wizard_step context["wizard_steps"] = [ @@ -107,10 +166,23 @@ class WizardViewMixin: class IntermediateAdminActionView(IntermediateAdminView, ABC): + """ + Abstract base view: Intermediate action view (preview & confirmation see :class:`IntermediateAdminView`) + for custom admin actions (marking multiple objects in a django admin model instances list with a checkmark and then + choosing an action from the dropdown). + + This will automatically handle the decoding of the URL encoding of the list of primary keys django does to select + which objects the action should be run on, then display a preview, perform the action after confirmation and + redirect again to the object list including display of a confirmation message + """ + # pylint: disable=no-member form_class = AdminIntermediateActionForm entities = None def get_queryset(self, pks=None): + """ + Get the queryset of objects to perform the action on + """ if pks is None: pks = self.request.GET['pks'] return self.model.objects.filter(pk__in=pks.split(",")) @@ -130,7 +202,10 @@ class IntermediateAdminActionView(IntermediateAdminView, ABC): @abstractmethod def action(self, form): - pass + """ + The real action to perform + :param form: form holding the data probably needed for the action + """ def form_valid(self, form): self.entities = self.get_queryset(pks=form.cleaned_data['pks']) @@ -140,7 +215,21 @@ class IntermediateAdminActionView(IntermediateAdminView, ABC): class LoopActionMixin(ABC): - def action(self, form): + """ + Mixin for the typical kind of action where one needs to loop over all elements + and perform a certain function on each of them + + The action is performed by overriding `perform_action(self, entity)` + further customization can be reached with the two callbacks `pre_action()` and `post_action()` + that are called before and after performing the action loop + """ + def action(self, form): # pylint: disable=unused-argument + """ + The real action to perform. + Will perform the loop, perform the action on each aelement and call the callbacks + + :param form: form holding the data probably needed for the action + """ self.pre_action() for entity in self.entities: self.perform_action(entity) @@ -149,10 +238,18 @@ class LoopActionMixin(ABC): @abstractmethod def perform_action(self, entity): - pass + """ + Action to perform on each entity + + :param entity: entity to perform the action on + """ def pre_action(self): - pass + """ + Callback for custom action before loop starts + """ def post_action(self): - pass + """ + Callback for custom action after loop finished + """ diff --git a/AKModel/metaviews/status.py b/AKModel/metaviews/status.py index 2e1c3777..5579a745 100644 --- a/AKModel/metaviews/status.py +++ b/AKModel/metaviews/status.py @@ -8,6 +8,9 @@ from AKModel.metaviews.admin import AdminViewMixin class StatusWidget(ABC): + """ + Abstract parent for status page widgets + """ title = "Status Widget" actions = [] status = "primary" @@ -18,7 +21,6 @@ class StatusWidget(ABC): """ Which model/context is needed to render this widget? """ - pass def get_context_data(self, context) -> dict: """ @@ -32,6 +34,7 @@ class StatusWidget(ABC): Render widget based on context :param context: Context for rendering + :param request: HTTP request, needed for rendering :return: Dictionary containing the rendered/prepared information """ context = self.get_context_data(context) @@ -42,7 +45,7 @@ class StatusWidget(ABC): "status": self.render_status(context), } - def render_title(self, context: {}) -> str: + def render_title(self, context: {}) -> str: # pylint: disable=unused-argument """ Render title for widget based on context @@ -52,7 +55,7 @@ class StatusWidget(ABC): """ return self.title - def render_status(self, context: {}) -> str: + def render_status(self, context: {}) -> str: # pylint: disable=unused-argument """ Render status for widget based on context @@ -63,16 +66,16 @@ class StatusWidget(ABC): return self.status @abstractmethod - def render_body(self, context: {}, request) -> str: + def render_body(self, context: {}, request) -> str: # pylint: disable=unused-argument """ Render body for widget based on context :param context: Context for rendering + :param request: HTTP request (needed for rendering) :return: Rendered widget body (HTML) """ - pass - def render_actions(self, context: {}) -> list[dict]: + def render_actions(self, context: {}) -> list[dict]: # pylint: disable=unused-argument """ Render actions for widget based on context @@ -81,16 +84,30 @@ class StatusWidget(ABC): :param context: Context for rendering :return: List of actions """ - return [a for a in self.actions] + return self.actions class TemplateStatusWidget(StatusWidget): + """ + A :class:`StatusWidget` that produces its content by rendering a given html template + """ @property @abstractmethod def template_name(self) -> str: - pass + """ + Configure the template to use + :return: name of the template to use + """ def render_body(self, context: {}, request) -> str: + """ + Render the body of the widget using the template rendering method from django + (load and render template using the provided context) + + :param context: context to use for rendering + :param request: HTTP request (needed by django) + :return: rendered content (HTML) + """ template = loader.get_template(self.template_name) return template.render(context, request) @@ -98,6 +115,8 @@ class TemplateStatusWidget(StatusWidget): class StatusManager: """ Registry for all status widgets + + Allows to register status widgets using the `@status_manager.register(name="xyz")` decorator """ widgets = {} widgets_by_context_type = defaultdict(list) @@ -131,6 +150,9 @@ class StatusManager: class StatusView(ABC, AdminViewMixin, TemplateView): + """ + Abstract view: A generic base view to create a status page holding multiple widgets + """ template_name = "admin/AKModel/status/status.html" @property @@ -139,12 +161,15 @@ class StatusView(ABC, AdminViewMixin, TemplateView): """ Which model/context is provided by this status view? """ - pass def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) - from AKModel.metaviews import status_manager - context['widgets'] = [w.render(context, self.request) for w in status_manager.get_by_context_type(self.provided_context_type)] + # Load status manager (local import to prevent cyclic import) + from AKModel.metaviews import status_manager # pylint: disable=import-outside-toplevel + + # Render all widgets and provide them as part of the context + context['widgets'] = [w.render(context, self.request) + for w in status_manager.get_by_context_type(self.provided_context_type)] return self.render_to_response(context) diff --git a/AKModel/models.py b/AKModel/models.py index 3b1a9da9..926b7e0e 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -14,7 +14,8 @@ from timezone_field import TimeZoneField class Event(models.Model): - """ An event supplies the frame for all Aks. + """ + An event supplies the frame for all Aks. """ name = models.CharField(max_length=64, unique=True, verbose_name=_('Name'), help_text=_('Name or iteration of the event')) @@ -50,8 +51,8 @@ class Event(models.Model): help_text=_('Default length in hours that is assumed for AKs in this event.')) contact_email = models.EmailField(verbose_name=_("Contact email address"), blank=True, - help_text=_( - "An email address that is displayed on every page and can be used for all kinds of questions")) + help_text=_("An email address that is displayed on every page " + "and can be used for all kinds of questions")) class Meta: verbose_name = _('Event') @@ -63,25 +64,37 @@ class Event(models.Model): @staticmethod def get_by_slug(slug): + """ + Get event by its slug + + :param slug: slug of the event + :return: event identified by the slug + :rtype: Event + """ return Event.objects.get(slug=slug) @staticmethod def get_next_active(): - # Get first active event taking place + """ + Get first active event taking place + :return: matching event (if any) or None + :rtype: Event + """ event = Event.objects.filter(active=True).order_by('start').first() # No active event? Return the next event taking place if event is None: event = Event.objects.order_by('start').filter(start__gt=datetime.now()).first() return event - def get_categories_with_aks(self, wishes_seperately=False, filter=lambda ak: True, hide_empty_categories=False): + def get_categories_with_aks(self, wishes_seperately=False, + filter_func=lambda ak: True, hide_empty_categories=False): """ Get AKCategories as well as a list of AKs belonging to the category for this event :param wishes_seperately: Return wishes as individual list. :type wishes_seperately: bool - :param filter: Optional filter predicate, only include AK in list if filter returns True - :type filter: (AK)->bool + :param filter_func: Optional filter predicate, only include AK in list if filter returns True + :type filter_func: (AK)->bool :return: list of category-AK-list-tuples, optionally the additional list of AK wishes :rtype: list[(AKCategory, list[AK])] [, list[AK]] """ @@ -89,11 +102,26 @@ class Event(models.Model): categories_with_aks = [] ak_wishes = [] + # Fill lists by iterating + # A different behavior is needed depending on whether wishes should show up inside their categories + # or as a separate category + + def _get_category_aks(category): + """ + Get all AKs belonging to a category + Use joining and prefetching to reduce the number of necessary SQL queries + + :param category: category the AKs should belong to + :return: QuerySet over AKs + :return: QuerySet[AK] + """ + return category.ak_set.select_related('event').prefetch_related('owners', 'akslot_set').all() + if wishes_seperately: for category in categories: ak_list = [] - for ak in category.ak_set.select_related('event').prefetch_related('owners', 'akslot_set').all(): - if filter(ak): + for ak in _get_category_aks(category): + if filter_func(ak): if ak.wish: ak_wishes.append(ak) else: @@ -101,21 +129,36 @@ class Event(models.Model): if not hide_empty_categories or len(ak_list) > 0: categories_with_aks.append((category, ak_list)) return categories_with_aks, ak_wishes - else: - for category in categories: - ak_list = [] - for ak in category.ak_set.all(): - if filter(ak): - ak_list.append(ak) - if not hide_empty_categories or len(ak_list) > 0: - categories_with_aks.append((category, ak_list)) - return categories_with_aks + + for category in categories: + ak_list = [] + for ak in _get_category_aks(category): + if filter_func(ak): + ak_list.append(ak) + if not hide_empty_categories or len(ak_list) > 0: + categories_with_aks.append((category, ak_list)) + return categories_with_aks def get_unscheduled_wish_slots(self): + """ + Get all slots of wishes that are currently not scheduled + :return: queryset of theses slots + :rtype: QuerySet[AKSlot] + """ return self.akslot_set.filter(start__isnull=True).annotate(Count('ak__owners')).filter(ak__owners__count=0) def get_aks_without_availabilities(self): - return self.ak_set.annotate(Count('availabilities', distinct=True)).annotate(Count('owners', distinct=True)).filter(availabilities__count=0, owners__count__gt=0) + """ + Gt all AKs that don't have any availability at all + + :return: generator over these AKs + :rtype: Generator[AK] + """ + return (self.ak_set + .annotate(Count('availabilities', distinct=True)) + .annotate(Count('owners', distinct=True)) + .filter(availabilities__count=0, owners__count__gt=0) + ) class AKOwner(models.Model): @@ -141,21 +184,34 @@ class AKOwner(models.Model): return self.name def _generate_slug(self): + """ + Auto-generate a slug for an owner + This will start with a very simple slug (the name truncated to a maximum length) and then gradually produce + more complicated slugs when the previous candidates are already used + + :return: the slug + :rtype: str + """ max_length = self._meta.get_field('slug').max_length + # Try name alone (truncated if necessary) slug_candidate = slugify(self.name)[:max_length] if not AKOwner.objects.filter(event=self.event, slug=slug_candidate).exists(): self.slug = slug_candidate return + + # Try name and institution separated by '_' (truncated if necessary) slug_candidate = slugify(slug_candidate + '_' + self.institution)[:max_length] if not AKOwner.objects.filter(event=self.event, slug=slug_candidate).exists(): self.slug = slug_candidate return + + # Try name + institution + an incrementing digit for i in itertools.count(1): if not AKOwner.objects.filter(event=self.event, slug=slug_candidate).exists(): break digits = len(str(i)) - slug_candidate = '{}-{}'.format(slug_candidate[:-(digits + 1)], i) + slug_candidate = f'{slug_candidate[:-(digits + 1)]}-{i}' self.slug = slug_candidate @@ -167,6 +223,15 @@ class AKOwner(models.Model): @staticmethod def get_by_slug(event, slug): + """ + Get owner by slug + Will be identified by the combination of event slug and owner slug which is unique + + :param event: event + :param slug: slug of the owner + :return: owner identified by slugs + :rtype: AKOwner + """ return AKOwner.objects.get(event=event, slug=slug) @@ -178,8 +243,8 @@ class AKCategory(models.Model): description = models.TextField(blank=True, verbose_name=_("Description"), help_text=_("Short description of this AK Category")) present_by_default = models.BooleanField(blank=True, default=True, verbose_name=_("Present by default"), - help_text=_( - "Present AKs of this category by default if AK owner did not specify whether this AK should be presented?")) + help_text=_("Present AKs of this category by default " + "if AK owner did not specify whether this AK should be presented?")) event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'), help_text=_('Associated event')) @@ -213,6 +278,11 @@ class AKTrack(models.Model): return self.name def aks_with_category(self): + """ + Get all AKs that belong to this track with category already joined to prevent additional SQL queries + :return: queryset over the AKs + :rtype: QuerySet[AK] + """ return self.ak_set.select_related('category').all() @@ -268,7 +338,8 @@ class AK(models.Model): help_text=_('AKs that should precede this AK in the schedule')) notes = models.TextField(blank=True, verbose_name=_('Organizational Notes'), help_text=_( - 'Notes to organizers. These are public. For private notes, please use the button for private messages on the detail page of this AK (after creation/editing).')) + 'Notes to organizers. These are public. For private notes, please use the button for private messages ' + 'on the detail page of this AK (after creation/editing).')) interest = models.IntegerField(default=-1, verbose_name=_('Interest'), help_text=_('Expected number of people')) interest_counter = models.IntegerField(default=0, verbose_name=_('Interest Counter'), @@ -295,8 +366,16 @@ class AK(models.Model): @property def details(self): + """ + Generate a detailled string representation, e.g., for usage in scheduling + :return: string representation of that AK with all details + :rtype: str + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel from AKModel.availability.models import Availability - availabilities = ', \n'.join(f'{a.simplified}' for a in Availability.objects.select_related('event').filter(ak=self)) + availabilities = ', \n'.join(f'{a.simplified}' for a in Availability.objects.select_related('event') + .filter(ak=self)) return f"""{self.name}{" (R)" if self.reso else ""}: {self.owners_list} @@ -309,34 +388,74 @@ class AK(models.Model): @property def owners_list(self): + """ + Get a list of stringified representations of all owners + + :return: list of owners + :rtype: List[str] + """ return ", ".join(str(owner) for owner in self.owners.all()) @property def durations_list(self): + """ + Get a list of stringified representations of all durations of associated slots + + :return: list of durations + :rtype: List[str] + """ return ", ".join(str(slot.duration_simplified) for slot in self.akslot_set.select_related('event').all()) @property def wish(self): + """ + Is the AK a wish? + :return: true if wish, false if not + :rtype: bool + """ return self.owners.count() == 0 def increment_interest(self): + """ + Increment the interest counter for this AK by one + without tracking that change to prevent an unreadable and large history + """ self.interest_counter += 1 - self.skip_history_when_saving = True + self.skip_history_when_saving = True # pylint: disable=attribute-defined-outside-init self.save() del self.skip_history_when_saving @property def availabilities(self): + """ + Get all availabilities associated to this AK + :return: availabilities + :rtype: QuerySet[Availability] + """ return "Availability".objects.filter(ak=self) @property def edit_url(self): + """ + Get edit URL for this AK + Will link to frontend if AKSubmission is active, otherwise to the edit view for this object in admin interface + + :return: URL + :rtype: str + """ if apps.is_installed("AKSubmission"): return reverse_lazy('submit:ak_edit', kwargs={'event_slug': self.event.slug, 'pk': self.id}) return reverse_lazy('admin:AKModel_ak_change', kwargs={'object_id': self.id}) @property def detail_url(self): + """ + Get detail URL for this AK + Will link to frontend if AKSubmission is active, otherwise to the edit view for this object in admin interface + + :return: URL + :rtype: str + """ if apps.is_installed("AKSubmission"): return reverse_lazy('submit:ak_detail', kwargs={'event_slug': self.event.slug, 'pk': self.id}) return self.edit_url @@ -364,6 +483,12 @@ class Room(models.Model): @property def title(self): + """ + Get title of a room, which consists of location and name if location is set, otherwise only the name + + :return: title + :rtype: str + """ if self.location: return f"{self.location} {self.name}" return self.name @@ -429,7 +554,8 @@ class AKSlot(models.Model): start = self.start.astimezone(self.event.timezone) end = self.end.astimezone(self.event.timezone) - return f"{start.strftime('%a %H:%M')} - {end.strftime('%H:%M') if start.day == end.day else end.strftime('%a %H:%M')}" + return (f"{start.strftime('%a %H:%M')} - " + f"{end.strftime('%H:%M') if start.day == end.day else end.strftime('%a %H:%M')}") @property def end(self): @@ -448,10 +574,20 @@ class AKSlot(models.Model): return (timezone.now() - self.updated).total_seconds() def overlaps(self, other: "AKSlot"): + """ + Check wether two slots overlap + + :param other: second slot to compare with + :return: true if they overlap, false if not: + :rtype: bool + """ return self.start < other.end <= self.end or self.start <= other.start < self.end class AKOrgaMessage(models.Model): + """ + Model representing confidential messages to the organizers/scheduling people, belonging to a certain AK + """ ak = models.ForeignKey(to=AK, on_delete=models.CASCADE, verbose_name=_('AK'), help_text=_('AK this message belongs to')) text = models.TextField(verbose_name=_("Message text"), @@ -470,12 +606,23 @@ class AKOrgaMessage(models.Model): class ConstraintViolation(models.Model): + """ + Model to represent any kind of constraint violation + + Can have two different severities: violation and warning + The list of possible types is defined in :class:`ViolationType` + Depending on the type, different fields (references to other models) will be filled. Each violation should always + be related to an event and at least on other instance of a causing entity + """ class Meta: verbose_name = _('Constraint Violation') verbose_name_plural = _('Constraint Violations') ordering = ['-timestamp'] class ViolationType(models.TextChoices): + """ + Possible types of violations with their text representation + """ OWNER_TWO_SLOTS = 'ots', _('Owner has two parallel slots') SLOT_OUTSIDE_AVAIL = 'soa', _('AK Slot was scheduled outside the AK\'s availabilities') ROOM_TWO_SLOTS = 'rts', _('Room has two AK slots scheduled at the same time') @@ -490,6 +637,9 @@ class ConstraintViolation(models.Model): SLOT_OUTSIDE_EVENT = 'soe', _('AK Slot is scheduled outside the event\'s availabilities') class ViolationLevel(models.IntegerChoices): + """ + Possible severities/levels of a CV + """ WARNING = 1, _('Warning') VIOLATION = 10, _('Violation') @@ -501,6 +651,7 @@ class ConstraintViolation(models.Model): event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'), help_text=_('Associated event')) + # Possible "causes": aks = models.ManyToManyField(to=AK, blank=True, verbose_name=_('AKs'), help_text=_('AK(s) belonging to this constraint')) ak_slots = models.ManyToManyField(to=AKSlot, blank=True, verbose_name=_('AK Slots'), @@ -551,22 +702,37 @@ class ConstraintViolation(models.Model): @property def details(self): + """ + Property: Details + """ return self.get_details() @property - def edit_url(self): + def edit_url(self) -> str: + """ + Property: Edit URL for this CV + """ return reverse_lazy('admin:AKModel_constraintviolation_change', kwargs={'object_id': self.pk}) @property - def level_display(self): + def level_display(self) -> str: + """ + Property: Severity as string + """ return self.get_level_display() @property - def type_display(self): + def type_display(self) -> str: + """ + Property: Type as string + """ return self.get_type_display() @property - def timestamp_display(self): + def timestamp_display(self) -> str: + """ + Property: Creation timestamp as string + """ return self.timestamp.astimezone(self.event.timezone).strftime('%d.%m.%y %H:%M') @property @@ -585,7 +751,10 @@ class ConstraintViolation(models.Model): return self.aks_tmp @property - def _aks_str(self): + def _aks_str(self) -> str: + """ + Property: AKs as string + """ if self.pk and self.pk > 0: return ', '.join(str(a) for a in self.aks.all()) return ', '.join(str(a) for a in self.aks_tmp) @@ -606,7 +775,10 @@ class ConstraintViolation(models.Model): return self.ak_slots_tmp @property - def _ak_slots_str(self): + def _ak_slots_str(self) -> str: + """ + Property: Slots as string + """ if self.pk and self.pk > 0: return ', '.join(str(a) for a in self.ak_slots.select_related('event').all()) return ', '.join(str(a) for a in self.ak_slots_tmp) @@ -655,6 +827,10 @@ class ConstraintViolation(models.Model): class DefaultSlot(models.Model): + """ + Model representing a default slot, + i.e., a prefered slot to use for typical AKs in the schedule to guarantee enough breaks etc. + """ class Meta: verbose_name = _('Default Slot') verbose_name_plural = _('Default Slots') @@ -670,19 +846,31 @@ class DefaultSlot(models.Model): help_text=_('Categories that should be assigned to this slot primarily')) @property - def start_simplified(self): + def start_simplified(self) -> str: + """ + Property: Simplified version of the start timetstamp (weekday, hour, minute) as string + """ return self.start.astimezone(self.event.timezone).strftime('%a %H:%M') @property - def start_iso(self): + def start_iso(self) -> str: + """ + Property: Start timestamp as ISO timestamp for usage in calendar views + """ return timezone.localtime(self.start, self.event.timezone).strftime("%Y-%m-%dT%H:%M:%S") @property - def end_simplified(self): + def end_simplified(self) -> str: + """ + Property: Simplified version of the end timetstamp (weekday, hour, minute) as string + """ return self.end.astimezone(self.event.timezone).strftime('%a %H:%M') @property - def end_iso(self): + def end_iso(self) -> str: + """ + Property: End timestamp as ISO timestamp for usage in calendar views + """ return timezone.localtime(self.end, self.event.timezone).strftime("%Y-%m-%dT%H:%M:%S") def __str__(self): diff --git a/AKModel/serializers.py b/AKModel/serializers.py index 8dbbb4ea..5d9ad603 100644 --- a/AKModel/serializers.py +++ b/AKModel/serializers.py @@ -4,36 +4,54 @@ from AKModel.models import AK, Room, AKSlot, AKTrack, AKCategory, AKOwner class AKOwnerSerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for AKOwner + """ class Meta: model = AKOwner fields = '__all__' class AKCategorySerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for AKCategory + """ class Meta: model = AKCategory fields = '__all__' class AKTrackSerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for AKTrack + """ class Meta: model = AKTrack fields = '__all__' class AKSerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for AK + """ class Meta: model = AK fields = '__all__' class RoomSerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for Room + """ class Meta: model = Room fields = '__all__' class AKSlotSerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for AKSlot + """ class Meta: model = AKSlot fields = '__all__' @@ -41,6 +59,9 @@ class AKSlotSerializer(serializers.ModelSerializer): treat_as_local = serializers.BooleanField(required=False, default=False, write_only=True) def create(self, validated_data:dict): + # Handle timezone adaption based upon the control field "treat_as_local": + # If it is set, ignore timezone submitted from the browser (will always be UTC) + # and treat it as input in the events timezone instead if validated_data['treat_as_local']: validated_data['start'] = validated_data['start'].replace(tzinfo=None).astimezone( validated_data['event'].timezone) diff --git a/AKModel/site.py b/AKModel/site.py index 480689ef..bff49850 100644 --- a/AKModel/site.py +++ b/AKModel/site.py @@ -1,17 +1,22 @@ from django.contrib.admin import AdminSite from django.utils.translation import gettext_lazy as _ +# from django.urls import path from AKModel.models import Event class AKAdminSite(AdminSite): + """ + Custom admin interface definition (extend the admin functionality of Django) + """ index_template = "admin/ak_index.html" site_header = f"AKPlanning - {_('Administration')}" index_title = _('Administration') def get_urls(self): - from django.urls import path - + """ + Get URLs -- add further views that are not related to a certain model here if needed + """ urls = super().get_urls() urls += [ # path('...', self.admin_view(...)), @@ -19,6 +24,8 @@ class AKAdminSite(AdminSite): return urls def index(self, request, extra_context=None): + # Override index page rendering to provide extra context (the list of active events) + # to be used in the adapted template if extra_context is None: extra_context = {} extra_context["active_events"] = Event.objects.filter(active=True) diff --git a/AKModel/templatetags/tags_AKModel.py b/AKModel/templatetags/tags_AKModel.py index 06560dc7..9ca14812 100644 --- a/AKModel/templatetags/tags_AKModel.py +++ b/AKModel/templatetags/tags_AKModel.py @@ -8,30 +8,58 @@ from fontawesome_6.app_settings import get_css register = template.Library() -# Get Footer Info from settings @register.simple_tag def footer_info(): + """ + Get Footer Info from settings + + :return: a dict of several strings like the impress URL to use in the footer + :rtype: Dict[str, str] + """ return settings.FOOTER_INFO @register.filter def check_app_installed(name): + """ + Check whether the app with the given name is active in this instance + + :param name: name of the app to check for + :return: true if app is installed + :rtype: bool + """ return apps.is_installed(name) @register.filter def message_bootstrap_class(tag): + """ + Turn message severity classes into corresponding bootstrap css classes + + :param tag: severity of the message + :return: matching bootstrap class + """ if tag == "error": return "alert-danger" - elif tag == "success": + if tag == "success": return "alert-success" - elif tag == "warning": + if tag == "warning": return "alert-warning" return "alert-info" @register.filter def wiki_owners_export(owners, event): + """ + Preserve owner link information for wiki export by using internal links if possible + but external links when owner specified a non-wikilink. This is applied to the full list of owners + + :param owners: list of owners + :param event: event this owner belongs to and that is currently exported + (specifying this directly prevents unnecesary database lookups) + :return: linkified owners list in wiki syntax + :rtype: str + """ def to_link(owner): if owner.link != '': event_link_prefix, _ = event.base_url.rsplit("/", 1) @@ -44,17 +72,30 @@ def wiki_owners_export(owners, event): return ", ".join(to_link(owner) for owner in owners.all()) +# get list of relevant css fontawesome css files for this instance css = get_css() @register.simple_tag def fontawesome_6_css(): + """ + Create html code to load all required fontawesome css files + + :return: HTML code to load css + :rtype: str + """ return mark_safe(conditional_escape('\n').join(format_html( '<link href="{}" rel="stylesheet" media="all">', stylesheet) for stylesheet in css)) @register.simple_tag def fontawesome_6_js(): + """ + Create html code to load all required fontawesome javascript files + + :return: HTML code to load js + :rtype: str + """ return mark_safe(format_html( '<script type="text/javascript" src="{}"></script>', static('fontawesome_6/js/django-fontawesome.js') - )) \ No newline at end of file + )) diff --git a/AKModel/tests.py b/AKModel/tests.py index d676992f..5b353c30 100644 --- a/AKModel/tests.py +++ b/AKModel/tests.py @@ -1,7 +1,7 @@ import traceback from typing import List -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.messages import get_messages from django.contrib.messages.storage.base import Message from django.test import TestCase @@ -12,21 +12,43 @@ from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, A class BasicViewTests: + """ + Parent class for "standard" tests of views + + Provided with a list of views and arguments (if necessary), this will test that views + - render correctly without errors + - are only reachable with the correct rights (neither too freely nor too restricted) + + To do this, the test creates sample users, fixtures are loaded automatically by the django test framework. + It also provides helper functions, e.g., to check for correct messages to the user or more simply generate + the URLs to test + + In this class, methods from :class:`TestCase` will be called at multiple places event though TestCase is not a + parent of this class but has to be included as parent in concrete implementations of this class seperately. + It however still makes sense to treat this class as some kind of mixin and not implement it as a child of TestCase, + since the test framework does not understand the concept of abstract test definitions and would handle this class + as real test case otherwise, distorting the test results. + """ + # pylint: disable=no-member VIEWS = [] APP_NAME = '' VIEWS_STAFF_ONLY = [] EDIT_TESTCASES = [] - def setUp(self): - self.staff_user = User.objects.create( + def setUp(self): # pylint: disable=invalid-name + """ + Setup testing by creating sample users + """ + user_model = get_user_model() + self.staff_user = user_model.objects.create( username='Test Staff User', email='teststaff@example.com', password='staffpw', is_staff=True, is_active=True ) - self.admin_user = User.objects.create( + self.admin_user = user_model.objects.create( username='Test Admin User', email='testadmin@example.com', password='adminpw', is_staff=True, is_superuser=True, is_active=True ) - self.deactivated_user = User.objects.create( + self.deactivated_user = user_model.objects.create( username='Test Deactivated User', email='testdeactivated@example.com', password='deactivatedpw', is_staff=True, is_active=False ) @@ -45,6 +67,13 @@ class BasicViewTests: return view_name_with_prefix, url def _assert_message(self, response, expected_message, msg_prefix=""): + """ + Assert that the correct message is shown and cause test to fail if not + + :param response: response to check + :param expected_message: message that should be shown + :param msg_prefix: prefix for the error message when test fails + """ messages:List[Message] = list(get_messages(response.wsgi_request)) msg_count = "No message shown to user" @@ -59,21 +88,30 @@ class BasicViewTests: self.assertEqual(messages[-1].message, expected_message, msg=msg_content) def test_views_for_200(self): + """ + Test the list of public views (as specified in "VIEWS") for error-free rendering + """ for view_name in self.VIEWS: view_name_with_prefix, url = self._name_and_url(view_name) try: response = self.client.get(url) self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) broken") - except Exception as e: - self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):\n\n{traceback.format_exc()}") + except Exception: # pylint: disable=broad-exception-caught + self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):" + f"\n\n{traceback.format_exc()}") def test_access_control_staff_only(self): + """ + Test whether internal views (as specified in "VIEWS_STAFF_ONLY" are visible to staff users and staff users only + """ + # Not logged in? Views should not be visible self.client.logout() for view_name in self.VIEWS_STAFF_ONLY: view_name_with_prefix, url = self._name_and_url(view_name) response = self.client.get(url) self.assertEqual(response.status_code, 302, msg=f"{view_name_with_prefix} ({url}) accessible by non-staff") + # Logged in? Views should be visible self.client.force_login(self.staff_user) for view_name in self.VIEWS_STAFF_ONLY: view_name_with_prefix, url = self._name_and_url(view_name) @@ -81,9 +119,11 @@ class BasicViewTests: response = self.client.get(url) self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)") - except Exception as e: - self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):\n\n{traceback.format_exc()}") + except Exception: # pylint: disable=broad-exception-caught + self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):" + f"\n\n{traceback.format_exc()}") + # Disabled user? Views should not be visible self.client.force_login(self.deactivated_user) for view_name in self.VIEWS_STAFF_ONLY: view_name_with_prefix, url = self._name_and_url(view_name) @@ -91,28 +131,37 @@ class BasicViewTests: self.assertEqual(response.status_code, 302, msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user") - def _to_sendable_value(self, v): + def _to_sendable_value(self, val): """ Create representation sendable via POST from form data - :param v: value to prepare - :type v: any + Needed to automatically check create, update and delete views + + :param val: value to prepare + :type val: any :return: prepared value (normally either raw value or primary key of complex object) """ - if type(v) == list: - return [e.pk for e in v] - if type(v) == "RelatedManager": - return [e.pk for e in v.all()] - return v + if isinstance(val, list): + return [e.pk for e in val] + if type(val) == "RelatedManager": # pylint: disable=unidiomatic-typecheck + return [e.pk for e in val.all()] + return val def test_submit_edit_form(self): """ - Test edit forms in the most simple way (sending them again unchanged) + Test edit forms (as specified in "EDIT_TESTCASES") in the most simple way (sending them again unchanged) """ for testcase in self.EDIT_TESTCASES: self._test_submit_edit_form(testcase) def _test_submit_edit_form(self, testcase): + """ + Test a single edit form by rendering and sending it again unchanged + + This will test for correct rendering, dispatching/redirecting, messages and access control handling + + :param testcase: details of the form to test + """ name, url = self._name_and_url((testcase["view"], testcase["kwargs"])) form_name = testcase.get("form_name", "form") expected_code = testcase.get("expected_code", 302) @@ -145,6 +194,9 @@ class BasicViewTests: class ModelViewTests(BasicViewTests, TestCase): + """ + Basic view test cases for views from AKModel plus some custom tests + """ fixtures = ['model.json'] ADMIN_MODELS = [ @@ -172,35 +224,48 @@ class ModelViewTests(BasicViewTests, TestCase): ] def test_admin(self): + """ + Test basic admin functionality (displaying and interacting with model instances) + """ self.client.force_login(self.admin_user) for model in self.ADMIN_MODELS: + # Special treatment for a subset of views (where we exchanged default functionality, e.g., create views) if model[1] == "event": - view_name_with_prefix, url = self._name_and_url((f'admin:new_event_wizard_start', {})) + _, url = self._name_and_url(('admin:new_event_wizard_start', {})) elif model[1] == "room": - view_name_with_prefix, url = self._name_and_url((f'admin:room-new', {})) + _, url = self._name_and_url(('admin:room-new', {})) + # Otherwise, just call the creation form view else: - view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {})) + _, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {})) response = self.client.get(url) self.assertEqual(response.status_code, 200, msg=f"Add form for model {model[1]} ({url}) broken") for model in self.ADMIN_MODELS: + # Test the update view using the first existing instance of each model m = model[0].objects.first() if m is not None: - view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_change', {'object_id': m.pk})) + _, url = self._name_and_url( + (f'admin:AKModel_{model[1]}_change', {'object_id': m.pk}) + ) response = self.client.get(url) self.assertEqual(response.status_code, 200, msg=f"Edit form for model {model[1]} ({url}) broken") def test_wiki_export(self): + """ + Test wiki export + This will test whether the view renders at all and whether the export list contains the correct AKs + """ self.client.force_login(self.admin_user) - export_url = reverse_lazy(f"admin:ak_wiki_export", kwargs={'slug': 'kif42'}) + export_url = reverse_lazy("admin:ak_wiki_export", kwargs={'slug': 'kif42'}) response = self.client.get(export_url) self.assertEqual(response.status_code, 200, "Export not working at all") export_count = 0 - for category, aks in response.context["categories_with_aks"]: + for _, aks in response.context["categories_with_aks"]: for ak in aks: - self.assertEqual(ak.include_in_export, True, f"AK with export flag set to False (pk={ak.pk}) included in export") + self.assertEqual(ak.include_in_export, True, + f"AK with export flag set to False (pk={ak.pk}) included in export") self.assertNotEqual(ak.pk, 1, "AK known to be excluded from export (PK 1) included in export") export_count += 1 diff --git a/AKModel/urls.py b/AKModel/urls.py index 80617814..763cc1a2 100644 --- a/AKModel/urls.py +++ b/AKModel/urls.py @@ -4,12 +4,14 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter import AKModel.views.api -from AKModel.views.manage import ExportSlidesView +from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \ NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardSettingsView +from AKModel.views.room import RoomBatchCreationView from AKModel.views.status import EventStatusView +# Register basic API views/endpoints api_router = DefaultRouter() api_router.register('akowner', AKModel.views.api.AKOwnerViewSet, basename='AKOwner') api_router.register('akcategory', AKModel.views.api.AKCategoryViewSet, basename='AKCategory') @@ -18,7 +20,9 @@ api_router.register('ak', AKModel.views.api.AKViewSet, basename='AK') api_router.register('room', AKModel.views.api.RoomViewSet, basename='Room') api_router.register('akslot', AKModel.views.api.AKSlotViewSet, basename='AKSlot') +# TODO Can we move this functionality to the individual apps instead? extra_paths = [] +# If AKScheduling is active, register additional API endpoints if apps.is_installed("AKScheduling"): from AKScheduling.api import ResourcesViewSet, RoomAvailabilitiesView, EventsView, EventsViewSet, \ ConstraintViolationsViewSet, DefaultSlotsView @@ -33,9 +37,10 @@ if apps.is_installed("AKScheduling"): name='scheduling-room-availabilities')), extra_paths.append(path('api/scheduling-default-slots/', DefaultSlotsView.as_view(), name='scheduling-default-slots')) + +#If AKSubmission is active, register an additional API endpoint for increasing the interest counter if apps.is_installed("AKSubmission"): from AKSubmission.api import increment_interest_counter - extra_paths.append(path('api/ak/<pk>/indicate-interest/', increment_interest_counter, name='submission-ak-indicate-interest')) event_specific_paths = [ @@ -45,6 +50,7 @@ event_specific_paths.extend(extra_paths) app_name = 'model' +# Included all these extra view paths at a path starting with the event slug urlpatterns = [ path( '<slug:event_slug>/', @@ -55,6 +61,9 @@ urlpatterns = [ def get_admin_urls_event_wizard(admin_site): + """ + Defines all additional URLs for the event creation wizard + """ return [ path('add/wizard/start/', admin_site.admin_view(NewEventWizardStartView.as_view()), name="new_event_wizard_start"), @@ -75,6 +84,9 @@ def get_admin_urls_event_wizard(admin_site): def get_admin_urls_event(admin_site): + """ + Defines all additional event-related view URLs that will be included in the event admin interface + """ return [ path('<slug:event_slug>/status/', admin_site.admin_view(EventStatusView.as_view()), name="event_status"), path('<slug:event_slug>/requirements/', admin_site.admin_view(AKRequirementOverview.as_view()), @@ -86,4 +98,10 @@ def get_admin_urls_event(admin_site): path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()), name="ak_delete_orga_messages"), path('<slug:event_slug>/ak-slide-export/', admin_site.admin_view(ExportSlidesView.as_view()), name="ak_slide_export"), + path('plan/publish/', admin_site.admin_view(PlanPublishView.as_view()), name="plan-publish"), + path('plan/unpublish/', admin_site.admin_view(PlanUnpublishView.as_view()), name="plan-unpublish"), + path('<slug:event_slug>/defaultSlots/', admin_site.admin_view(DefaultSlotEditorView.as_view()), + name="default-slots-editor"), + path('<slug:event_slug>/importRooms/', admin_site.admin_view(RoomBatchCreationView.as_view()), + name="room-import"), ] diff --git a/AKModel/views/ak.py b/AKModel/views/ak.py index 640d398e..3afec5ac 100644 --- a/AKModel/views/ak.py +++ b/AKModel/views/ak.py @@ -9,6 +9,9 @@ from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView): + """ + View: Display requirements for the given event + """ model = AKRequirement context_object_name = "requirements" title = _("Requirements for Event") @@ -22,6 +25,9 @@ class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView): class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): + """ + View: Export all AK slots of this event in CSV format ordered by tracks + """ template_name = "admin/AKModel/ak_csv_export.html" model = AKSlot context_object_name = "slots" @@ -30,12 +36,12 @@ class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): def get_queryset(self): return super().get_queryset().order_by("ak__track") - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - return context - class AKWikiExportView(AdminViewMixin, DetailView): + """ + View: Export AKs of this event in wiki syntax + This will show one text field per category, with a separate category/field for wishes + """ template_name = "admin/AKModel/wiki_export.html" model = Event context_object_name = "event" @@ -46,7 +52,7 @@ class AKWikiExportView(AdminViewMixin, DetailView): categories_with_aks, ak_wishes = context["event"].get_categories_with_aks( wishes_seperately=True, - filter=lambda ak: ak.include_in_export + filter_func=lambda ak: ak.include_in_export ) context["categories_with_aks"] = [(category.name, ak_list) for category, ak_list in categories_with_aks] @@ -56,10 +62,18 @@ class AKWikiExportView(AdminViewMixin, DetailView): class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView): + """ + View: Confirmation page to delete confidential AK-related messages to orga + + Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView` + """ template_name = "admin/AKModel/message_delete.html" title = _("Delete AK Orga Messages") def get_orga_messages_for_event(self, event): + """ + Get all orga messages for the given event + """ return AKOrgaMessage.objects.filter(ak__event=event) def get_success_url(self): @@ -77,6 +91,11 @@ class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView): class AKResetInterestView(IntermediateAdminActionView): + """ + View: Confirmation page to reset all manually specified interest values + + Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView` + """ title = _("Reset interest in AKs") model = AK confirmation_message = _("Interest of the following AKs will be set to not filled (-1):") @@ -87,6 +106,11 @@ class AKResetInterestView(IntermediateAdminActionView): class AKResetInterestCounterView(IntermediateAdminActionView): + """ + View: Confirmation page to reset all interest counters (online interest indication) + + Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView` + """ title = _("Reset AKs' interest counters") model = AK confirmation_message = _("Interest counter of the following AKs will be set to 0:") diff --git a/AKModel/views/api.py b/AKModel/views/api.py index abf4c261..06ef5abf 100644 --- a/AKModel/views/api.py +++ b/AKModel/views/api.py @@ -7,6 +7,10 @@ from AKModel.serializers import AKOwnerSerializer, AKCategorySerializer, AKTrack class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: Owners (restricted to those of the given event) + Read-only + """ permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) serializer_class = AKOwnerSerializer @@ -15,6 +19,10 @@ class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModel class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: Categories (restricted to those of the given event) + Read-only + """ permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) serializer_class = AKCategorySerializer @@ -24,6 +32,10 @@ class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListMo class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: Tracks (restricted to those of the given event) + Read, Write, Delete + """ permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) serializer_class = AKTrackSerializer @@ -33,6 +45,10 @@ class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateMod class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: AKs (restricted to those of the given event) + Read, Write + """ permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) serializer_class = AKSerializer @@ -41,6 +57,10 @@ class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMix class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: Rooms (restricted to those of the given event) + Read-only + """ permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) serializer_class = RoomSerializer @@ -50,6 +70,10 @@ class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMix class AKSlotViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: AK slots (restricted to those of the given event) + Read, Write + """ permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) serializer_class = AKSlotSerializer diff --git a/AKModel/views/event_wizard.py b/AKModel/views/event_wizard.py index 2aca3660..76a401a1 100644 --- a/AKModel/views/event_wizard.py +++ b/AKModel/views/event_wizard.py @@ -12,6 +12,12 @@ from AKModel.models import Event class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView): + """ + Wizard view: Entry/Start + + Specify basic settings, especially the timezone for correct time treatment in the next view + (:class:`NewEventWizardSettingsView`) where this view will redirect to without saving the new event already + """ model = Event form_class = NewEventWizardStartForm template_name = "admin/AKModel/event_wizard/start.html" @@ -19,6 +25,16 @@ class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView): class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView): + """ + Wizard view: Event settings + + Specify most of the event settings. The user will see that certain fields are required since they were lead here + from another form in :class:`NewEventWizardStartView` that did not contain these fields even though they are + mandatory for the event model + + Next step will then be :class:`NewEventWizardPrepareImportView` to prepare copy configuration elements + from an existing event + """ model = Event form_class = NewEventWizardSettingsForm template_name = "admin/AKModel/event_wizard/settings.html" @@ -34,6 +50,14 @@ class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView): class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView): + """ + Wizard view: Choose event to copy configuration elements from + + The user can here select an existing event to copy elements like requirements, categories and dashboard buttons from + The exact subset of elements to copy from can then be selected in the next view (:class:`NewEventWizardImportView`) + + Instead, this step can be skipped by directly continuing with :class:`NewEventWizardActivateView` + """ form_class = NewEventWizardPrepareImportForm template_name = "admin/AKModel/event_wizard/created_prepare_import.html" wizard_step = 3 @@ -45,29 +69,40 @@ class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView) class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView): + """ + Wizard view: Select configuration elements to copy + + Displays lists of requirements, categories and dashboard buttons that the user can select entries to be copied from + + Afterwards, the event can be activated in :class:`NewEventWizardActivateView` + """ form_class = NewEventWizardImportForm template_name = "admin/AKModel/event_wizard/import.html" wizard_step = 4 def get_initial(self): initial = super().get_initial() + # Remember which event was selected and send it again when submitting the form for validation initial["import_event"] = Event.objects.get(slug=self.kwargs["import_slug"]) return initial def form_valid(self, form): + # pylint: disable=consider-using-f-string import_types = ["import_categories", "import_requirements"] if apps.is_installed("AKDashboard"): import_types.append("import_buttons") + # Loop over all kinds of configuration elements and then over all selected elements of each type + # and try to clone them by requesting a new primary key, adapting the event and then storing the + # object in the database for import_type in import_types: for import_obj in form.cleaned_data.get(import_type): - # clone existing entry try: import_obj.event = self.event import_obj.pk = None import_obj.save() messages.add_message(self.request, messages.SUCCESS, _("Copied '%(obj)s'" % {'obj': import_obj})) - except BaseException as e: + except BaseException as e: # pylint: disable=broad-exception-caught messages.add_message(self.request, messages.ERROR, _("Could not copy '%(obj)s' (%(error)s)" % {'obj': import_obj, "error": str(e)})) @@ -75,6 +110,17 @@ class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView): class NewEventWizardActivateView(WizardViewMixin, UpdateView): + """ + Wizard view: Allow activating the event + + The user is asked to make the created event active. This is done in this step and not already during the creation + in the second step of the wizard to prevent users seeing an unconfigured submission. + The event will nevertheless already be visible in the dashboard before, when a public event was created in + :class:`NewEventWizardSettingsView`. + + In the following last step (:class:`NewEventWizardFinishView`), a confirmation of the full process and some + details of the created event are shown + """ model = Event template_name = "admin/AKModel/event_wizard/activate.html" form_class = NewEventWizardActivateForm @@ -85,6 +131,11 @@ class NewEventWizardActivateView(WizardViewMixin, UpdateView): class NewEventWizardFinishView(WizardViewMixin, DetailView): + """ + Wizard view: Confirmation and summary + + Show a confirmation and a summary of the created event + """ model = Event template_name = "admin/AKModel/event_wizard/finish.html" wizard_step = 6 diff --git a/AKModel/views/manage.py b/AKModel/views/manage.py index 369395d5..6f01c10d 100644 --- a/AKModel/views/manage.py +++ b/AKModel/views/manage.py @@ -18,16 +18,28 @@ from AKModel.models import ConstraintViolation, Event, DefaultSlot class UserView(TemplateView): + """ + View: Start page for logged in user + + Will over a link to backend or inform the user that their account still needs to be confirmed + """ template_name = "AKModel/user.html" class ExportSlidesView(EventSlugMixin, IntermediateAdminView): + """ + View: Export slides to present AKs + + Over a form to choose some settings for the export and then generate the PDF + """ title = _('Export AK Slides') form_class = SlideExportForm def form_valid(self, form): + # pylint: disable=invalid-name template_name = 'admin/AKModel/export/slides.tex' + # Settings NEXT_AK_LIST_LENGTH = form.cleaned_data['num_next'] RESULT_PRESENTATION_MODE = form.cleaned_data["presentation_mode"] SPACE_FOR_NOTES_IN_WISHES = form.cleaned_data["wish_notes"] @@ -42,12 +54,18 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView): } def build_ak_list_with_next_aks(ak_list): + """ + Create a list of tuples cosisting of an AK and a list of upcoming AKs (list length depending on setting) + """ next_aks_list = zip_longest(*[ak_list[i + 1:] for i in range(NEXT_AK_LIST_LENGTH)], fillvalue=None) - return [(ak, next_aks) for ak, next_aks in zip_longest(ak_list, next_aks_list, fillvalue=list())] + return [(ak, next_aks) for ak, next_aks in zip_longest(ak_list, next_aks_list, fillvalue=[])] - categories_with_aks, ak_wishes = self.event.get_categories_with_aks(wishes_seperately=True, filter=lambda + # Get all relevant AKs (wishes separately, and either all AKs or only those who should directly or indirectly + # be presented when restriction setting was chosen) + categories_with_aks, ak_wishes = self.event.get_categories_with_aks(wishes_seperately=True, filter_func=lambda ak: not RESULT_PRESENTATION_MODE or (ak.present or (ak.present is None and ak.category.present_by_default))) + # Create context for LaTeX rendering context = { 'title': self.event.name, 'categories_with_aks': [(category, build_ak_list_with_next_aks(ak_list)) for category, ak_list in @@ -67,11 +85,17 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView): os.remove(f'{tempdir}/texput.tex') pdf = run_tex_in_directory(source, tempdir, template_name=self.template_name) + # Show PDF file to the user (with a filename containing a timestamp to prevent confusions about the right + # version to use when generating multiple versions of the slides, e.g., because owners did last-minute changes + # to their AKs timestamp = datetime.datetime.now(tz=self.event.timezone).strftime("%Y-%m-%d_%H_%M") return PDFResponse(pdf, filename=f'{self.event.slug}_ak_slides_{timestamp}.pdf') class CVMarkResolvedView(IntermediateAdminActionView): + """ + Admin action view: Mark one or multitple constraint violation(s) as resolved + """ title = _('Mark Constraint Violations as manually resolved') model = ConstraintViolation confirmation_message = _("The following Constraint Violations will be marked as manually resolved") @@ -82,6 +106,9 @@ class CVMarkResolvedView(IntermediateAdminActionView): class CVSetLevelViolationView(IntermediateAdminActionView): + """ + Admin action view: Set one or multitple constraint violation(s) as to level "violation" + """ title = _('Set Constraint Violations to level "violation"') model = ConstraintViolation confirmation_message = _("The following Constraint Violations will be set to level 'violation'") @@ -92,6 +119,9 @@ class CVSetLevelViolationView(IntermediateAdminActionView): class CVSetLevelWarningView(IntermediateAdminActionView): + """ + Admin action view: Set one or multitple constraint violation(s) as to level "warning" + """ title = _('Set Constraint Violations to level "warning"') model = ConstraintViolation confirmation_message = _("The following Constraint Violations will be set to level 'warning'") @@ -102,6 +132,9 @@ class CVSetLevelWarningView(IntermediateAdminActionView): class PlanPublishView(IntermediateAdminActionView): + """ + Admin action view: Publish the plan of one or multitple event(s) + """ title = _('Publish plan') model = Event confirmation_message = _('Publish the plan(s) of:') @@ -112,6 +145,9 @@ class PlanPublishView(IntermediateAdminActionView): class PlanUnpublishView(IntermediateAdminActionView): + """ + Admin action view: Unpublish the plan of one or multitple event(s) + """ title = _('Unpublish plan') model = Event confirmation_message = _('Unpublish the plan(s) of:') @@ -122,6 +158,9 @@ class PlanUnpublishView(IntermediateAdminActionView): class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): + """ + Admin view: Allow to edit the default slots of an event + """ template_name = "admin/AKModel/default_slot_editor.html" form_class = DefaultSlotEditorForm title = _("Edit Default Slots") @@ -149,13 +188,14 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): previous_slot_ids = set(s.id for s in self.event.defaultslot_set.all()) + # Loop over inputs and update or add slots for slot in default_slots_raw: start = parse_datetime(slot["start"]).replace(tzinfo=tz) end = parse_datetime(slot["end"]).replace(tzinfo=tz) if slot["id"] != '': - id = int(slot["id"]) - if id not in previous_slot_ids: + slot_id = int(slot["id"]) + if slot_id not in previous_slot_ids: # Make sure only slots (currently) belonging to this event are edited # (user did not manipulate IDs and slots have not been deleted in another session in the meantime) messages.add_message( @@ -166,8 +206,8 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): ) else: # Update existing entries - previous_slot_ids.remove(id) - original_slot = DefaultSlot.objects.get(id=id) + previous_slot_ids.remove(slot_id) + original_slot = DefaultSlot.objects.get(id=slot_id) if original_slot.start != start or original_slot.end != end: original_slot.start = start original_slot.end = end @@ -187,6 +227,7 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): for d_id in previous_slot_ids: DefaultSlot.objects.get(id=d_id).delete() + # Inform user about changes performed if created_count + updated_count + deleted_count > 0: messages.add_message( self.request, diff --git a/AKModel/views/room.py b/AKModel/views/room.py index adead3ba..138a04a0 100644 --- a/AKModel/views/room.py +++ b/AKModel/views/room.py @@ -15,6 +15,9 @@ from AKModel.models import Room class RoomCreationView(AdminViewMixin, CreateView): + """ + Admin view: Create a room + """ form_class = RoomForm template_name = 'admin/AKModel/room_create.html' @@ -22,18 +25,28 @@ class RoomCreationView(AdminViewMixin, CreateView): print(self.request.POST['save_action']) if self.request.POST['save_action'] == 'save_add_another': return reverse_lazy('admin:room-new') - elif self.request.POST['save_action'] == 'save_continue': + if self.request.POST['save_action'] == 'save_continue': return reverse_lazy('admin:AKModel_room_change', kwargs={'object_id': self.room.pk}) - else: - return reverse_lazy('admin:AKModel_room_changelist') + return reverse_lazy('admin:AKModel_room_changelist') def form_valid(self, form): - self.room = form.save() + self.room = form.save() # pylint: disable=attribute-defined-outside-init + + # translatable string with placeholders, no f-string possible + # pylint: disable=consider-using-f-string messages.success(self.request, _("Created Room '%(room)s'" % {'room': self.room})) + return HttpResponseRedirect(self.get_success_url()) class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView): + """ + Admin action: Allow to create rooms in batch by inputing a CSV-formatted list of room details into a textbox + + This offers the input form, supports creation of virtual rooms if AKOnline is active, too, + and users can specify that default availabilities (from event start to end) should be created for the rooms + automatically + """ form_class = RoomBatchCreationForm title = _("Import Rooms from CSV") @@ -47,23 +60,33 @@ class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView): rooms_raw_dict: csv.DictReader = form.cleaned_data["rooms"] + # Prepare creation of virtual rooms if there is information (an URL) in the data and the AKOnline app is active if apps.is_installed("AKOnline") and "url" in rooms_raw_dict.fieldnames: virtual_rooms_support = True + # pylint: disable=import-outside-toplevel from AKOnline.models import VirtualRoom + # Loop over all inputs for raw_room in rooms_raw_dict: + # Gather the relevant information (most fields can be empty) name = raw_room["name"] location = raw_room["location"] if "location" in rooms_raw_dict.fieldnames else "" capacity = raw_room["capacity"] if "capacity" in rooms_raw_dict.fieldnames else -1 try: + # Try to create a room (catches cases where the room name contains keywords or symbols that the + # database cannot handle (.e.g., special UTF-8 characters) r = Room.objects.create(name=name, location=location, capacity=capacity, event=self.event) + + # and if necessary an associated virtual room, too if virtual_rooms_support and raw_room["url"] != "": VirtualRoom.objects.create(room=r, url=raw_room["url"]) + + # If user requested default availabilities, create them if create_default_availabilities: a = Availability.with_event_length(event=self.event, room=r) a.save() @@ -72,6 +95,7 @@ class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView): messages.add_message(self.request, messages.WARNING, _("Could not import room {name}: {e}").format(name=name, e=str(e))) + # Inform the user about the rooms created if created_count > 0: messages.add_message(self.request, messages.SUCCESS, _("Imported {count} room(s)").format(count=created_count)) diff --git a/AKModel/views/status.py b/AKModel/views/status.py index 460c13af..11173d97 100644 --- a/AKModel/views/status.py +++ b/AKModel/views/status.py @@ -4,12 +4,15 @@ from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from AKModel.metaviews import status_manager -from AKModel.metaviews.admin import EventSlugMixin, AdminViewMixin +from AKModel.metaviews.admin import EventSlugMixin from AKModel.metaviews.status import TemplateStatusWidget, StatusView @status_manager.register(name="event_overview") class EventOverviewWidget(TemplateStatusWidget): + """ + Status page widget: Event overview + """ required_context_type = "event" title = _("Overview") template_name = "admin/AKModel/status/event_overview.html" @@ -20,6 +23,12 @@ class EventOverviewWidget(TemplateStatusWidget): @status_manager.register(name="event_categories") class EventCategoriesWidget(TemplateStatusWidget): + """ + Status page widget: Category information + + Show all categories of the event together with the number of AKs belonging to this category. + Offers an action to add a new category. + """ required_context_type = "event" title = _("Categories") template_name = "admin/AKModel/status/event_categories.html" @@ -31,7 +40,8 @@ class EventCategoriesWidget(TemplateStatusWidget): ] def render_title(self, context: {}) -> str: - self.category_count = context['event'].akcategory_set.count() + # Store category count as instance variable for re-use in body + self.category_count = context['event'].akcategory_set.count() # pylint: disable=attribute-defined-outside-init return f"{super().render_title(context)} ({self.category_count})" def render_status(self, context: {}) -> str: @@ -40,6 +50,12 @@ class EventCategoriesWidget(TemplateStatusWidget): @status_manager.register(name="event_rooms") class EventRoomsWidget(TemplateStatusWidget): + """ + Status page widget: Category information + + Show all rooms of the event. + Offers actions to add a single new room as well as for batch creation. + """ required_context_type = "event" title = _("Rooms") template_name = "admin/AKModel/status/event_rooms.html" @@ -51,7 +67,8 @@ class EventRoomsWidget(TemplateStatusWidget): ] def render_title(self, context: {}) -> str: - self.room_count = context['event'].room_set.count() + # Store room count as instance variable for re-use in body + self.room_count = context['event'].room_set.count() # pylint: disable=attribute-defined-outside-init return f"{super().render_title(context)} ({self.room_count})" def render_status(self, context: {}) -> str: @@ -59,6 +76,7 @@ class EventRoomsWidget(TemplateStatusWidget): def render_actions(self, context: {}) -> list[dict]: actions = super().render_actions(context) + # Action has to be added here since it depends on the event for URL building actions.append( { "text": _("Import Rooms from CSV"), @@ -70,6 +88,12 @@ class EventRoomsWidget(TemplateStatusWidget): @status_manager.register(name="event_aks") class EventAKsWidget(TemplateStatusWidget): + """ + Status page widget: AK information + + Show information about the AKs of this event. + Offers a long list of AK-related actions and also scheduling actions of AKScheduling is active + """ required_context_type = "event" title = _("AKs") template_name = "admin/AKModel/status/event_aks.html" @@ -101,7 +125,9 @@ class EventAKsWidget(TemplateStatusWidget): { "text": _("Enter Interest"), "url": reverse_lazy("admin:enter-interest", - kwargs={"event_slug": context["event"].slug, "pk": context["event"].ak_set.all().first().pk}), + kwargs={"event_slug": context["event"].slug, + "pk": context["event"].ak_set.all().first().pk} + ), }, ]) actions.extend([ @@ -132,11 +158,19 @@ class EventAKsWidget(TemplateStatusWidget): @status_manager.register(name="event_requirements") class EventRequirementsWidget(TemplateStatusWidget): + """ + Status page widget: Requirement information information + + Show information about the requirements of this event. + Offers actions to add new requirements or to get a list of AKs having a given requirement. + """ required_context_type = "event" title = _("Requirements") template_name = "admin/AKModel/status/event_requirements.html" def render_title(self, context: {}) -> str: + # Store requirements count as instance variable for re-use in body + # pylint: disable=attribute-defined-outside-init self.requirements_count = context['event'].akrequirement_set.count() return f"{super().render_title(context)} ({self.requirements_count})" @@ -154,6 +188,9 @@ class EventRequirementsWidget(TemplateStatusWidget): class EventStatusView(EventSlugMixin, StatusView): + """ + View: Show a status dashboard for the given event + """ title = _("Event Status") provided_context_type = "event" -- GitLab