diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 741d4f59f623b4acc32c6be23e1ddb4710737c8a..53e961a22e2e4f6bfe923d659b269f11bae418bf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,16 +18,16 @@ before_script: - python -V # Print out python version for debugging - apt-get -qq update - apt-get -qq install -y python3-virtualenv python3 python3-dev python3-pip gettext default-mysql-client default-libmysqlclient-dev - - export DJANGO_SETTINGS_MODULE=AKPlanning.settings_ci - - ./Utils/setup.sh --prod + - ./Utils/setup.sh --ci + - mkdir -p public/badges public/lint + - echo undefined > public/badges/$CI_JOB_NAME.score + - source venv/bin/activate + - pip install pylint-gitlab pylint-django - mysql --version check: script: - ./Utils/check.sh --all - -check-migrations: - script: - source venv/bin/activate - ./manage.py makemigrations --dry-run --check @@ -48,3 +48,42 @@ test: coverage_format: cobertura path: coverage.xml junit: unit.xml + +lint: + stage: test + script: + - pylint --load-plugins pylint_django --django-settings-module=AKPlanning.settings_ci --rcfile pylintrc --exit-zero --output-format=text AK* | tee /tmp/pylint.txt + - sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' /tmp/pylint.txt > public/badges/$CI_JOB_NAME.score + - pylint --load-plugins pylint_django --django-settings-module=AKPlanning.settings_ci --rcfile pylintrc --exit-zero --output-format=pylint_gitlab.GitlabCodeClimateReporter AK* > codeclimate.json + - pylint --load-plugins pylint_django --django-settings-module=AKPlanning.settings_ci --rcfile pylintrc --exit-zero --output-format=pylint_gitlab.GitlabPagesHtmlReporter AK* > public/lint/index.html + after_script: + - | + echo "Linting score: $(cat public/badges/$CI_JOB_NAME.score)" + artifacts: + paths: + - public + reports: + codequality: codeclimate.json + when: always + +doc: + stage: test + script: + - cd docs + - make html + - cd .. + artifacts: + paths: + - docs/_build/html + +pages: + stage: deploy + image: alpine:latest + script: + - echo + artifacts: + paths: + - public + only: + refs: + - main diff --git a/AKDashboard/admin.py b/AKDashboard/admin.py index 724ac31cb275d61c92e01cb3b4c0d65e3af288ea..ffc7b41c68ac7a013606f4388dc867fbd47ee4f9 100644 --- a/AKDashboard/admin.py +++ b/AKDashboard/admin.py @@ -4,6 +4,9 @@ from AKDashboard.models import DashboardButton @admin.register(DashboardButton) class DashboardButtonAdmin(admin.ModelAdmin): + """ + Admin interface for dashboard buttons + """ list_display = ['text', 'url', 'event'] list_filter = ['event'] search_fields = ['text', 'url'] diff --git a/AKDashboard/apps.py b/AKDashboard/apps.py index ad0d431686f975c681fa583b0fcd1438653c2af0..c71dc24053822f11dd86ad7a1b4a608a8c6a417a 100644 --- a/AKDashboard/apps.py +++ b/AKDashboard/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class AkdashboardConfig(AppConfig): + """ + App configuration for dashboard (default) + """ name = 'AKDashboard' diff --git a/AKDashboard/locale/de_DE/LC_MESSAGES/django.po b/AKDashboard/locale/de_DE/LC_MESSAGES/django.po index 40921dd1daec106fc37a5e8a1e12bde0fae98607..b630ca873d6607cd947ef5ba8ef67cbec7f3844a 100644 --- a/AKDashboard/locale/de_DE/LC_MESSAGES/django.po +++ b/AKDashboard/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-15 20:03+0200\n" +"POT-Creation-Date: 2023-08-16 16:30+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" @@ -17,47 +17,47 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: AKDashboard/models.py:10 +#: AKDashboard/models.py:21 msgid "Dashboard Button" msgstr "Dashboard-Button" -#: AKDashboard/models.py:11 +#: AKDashboard/models.py:22 msgid "Dashboard Buttons" msgstr "Dashboard-Buttons" -#: AKDashboard/models.py:21 +#: AKDashboard/models.py:32 msgid "Text" msgstr "Text" -#: AKDashboard/models.py:22 +#: AKDashboard/models.py:33 msgid "Text that will be shown on the button" msgstr "Text, der auf dem Button angezeigt wird" -#: AKDashboard/models.py:23 +#: AKDashboard/models.py:34 msgid "Link URL" msgstr "Link-URL" -#: AKDashboard/models.py:23 +#: AKDashboard/models.py:34 msgid "URL this button links to" msgstr "URL auf die der Button verweist" -#: AKDashboard/models.py:24 +#: AKDashboard/models.py:35 msgid "Icon" msgstr "Symbol" -#: AKDashboard/models.py:26 +#: AKDashboard/models.py:37 msgid "Button Style" msgstr "Stil des Buttons" -#: AKDashboard/models.py:26 +#: AKDashboard/models.py:37 msgid "Style (Color) of this button (bootstrap class)" msgstr "Stiel (Farbe) des Buttons (Bootstrap-Klasse)" -#: AKDashboard/models.py:28 +#: AKDashboard/models.py:39 msgid "Event" msgstr "Veranstaltung" -#: AKDashboard/models.py:28 +#: AKDashboard/models.py:39 msgid "Event this button belongs to" msgstr "Veranstaltung, zu der dieser Button gehört" @@ -105,22 +105,22 @@ msgstr "AK-Einreichung" msgid "AK History" msgstr "AK-Verlauf" -#: AKDashboard/views.py:42 +#: AKDashboard/views.py:59 #, python-format msgid "New AK: %(ak)s." msgstr "Neuer AK: %(ak)s." -#: AKDashboard/views.py:45 +#: AKDashboard/views.py:62 #, python-format msgid "AK \"%(ak)s\" edited." msgstr "AK \"%(ak)s\" bearbeitet." -#: AKDashboard/views.py:48 +#: AKDashboard/views.py:65 #, python-format msgid "AK \"%(ak)s\" deleted." msgstr "AK \"%(ak)s\" gelöscht." -#: AKDashboard/views.py:60 +#: AKDashboard/views.py:80 #, python-format msgid "AK \"%(ak)s\" (re-)scheduled." msgstr "AK \"%(ak)s\" (um-)geplant." diff --git a/AKDashboard/models.py b/AKDashboard/models.py index 130a8e37889624047d4bda8f945b4bdbf8455506..6691e962ec149aac76667cd8ada71bc16738df68 100644 --- a/AKDashboard/models.py +++ b/AKDashboard/models.py @@ -6,6 +6,17 @@ from AKModel.models import Event class DashboardButton(models.Model): + """ + Model for a single dashboard button + + Allows to specify + * a text (currently without possibility to translate), + * a color (based on predefined design colors) + * a url the button should point to (internal or external) + * an icon (from the collection of fontawesome) + + Each button is associated with a single event and will be deleted when the event is deleted. + """ class Meta: verbose_name = _("Dashboard Button") verbose_name_plural = _("Dashboard Buttons") diff --git a/AKDashboard/tests.py b/AKDashboard/tests.py index 9610f5a22ad8f68aea735342f1fd18d034ba5c7b..0b03241c95a7461c0f3064acfa1c8083baae0aa8 100644 --- a/AKDashboard/tests.py +++ b/AKDashboard/tests.py @@ -10,8 +10,14 @@ from AKModel.tests import BasicViewTests class DashboardTests(TestCase): + """ + Specific Dashboard Tests + """ @classmethod def setUpTestData(cls): + """ + Initialize Test database + """ super().setUpTestData() cls.event = Event.objects.create( name="Dashboard Test Event", @@ -28,17 +34,30 @@ class DashboardTests(TestCase): ) def test_dashboard_view(self): + """ + Check that the main dashboard is reachable + (would also be covered by generic view testcase below) + """ url = reverse('dashboard:dashboard_event', kwargs={"slug": self.event.slug}) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_nonexistent_dashboard_view(self): + """ + Make sure there is no dashboard for an non-existing event + """ url = reverse('dashboard:dashboard_event', kwargs={"slug": "nonexistent-event"}) response = self.client.get(url) self.assertEqual(response.status_code, 404) @override_settings(DASHBOARD_SHOW_RECENT=True) def test_history(self): + """ + Test displaying of history + + For the sake of that test, the setting to show recent events in dashboard is enforced to be true + regardless of the default configuration currently in place + """ url = reverse('dashboard:dashboard_event', kwargs={"slug": self.event.slug}) # History should be empty @@ -57,6 +76,11 @@ class DashboardTests(TestCase): self.assertEqual(response.context["recent_changes"][0]['text'], "New AK: Test AK.") def test_public(self): + """ + Test handling of public and private events + (only public events should be part of the standard dashboard, + but there should be an individual dashboard for both public and private events) + """ url_dashboard_index = reverse('dashboard:dashboard') url_event_dashboard = reverse('dashboard:dashboard_event', kwargs={"slug": self.event.slug}) @@ -79,6 +103,9 @@ class DashboardTests(TestCase): self.assertTrue(self.event in response.context["events"]) def test_active(self): + """ + Test existence of buttons with regard to activity status of the given event + """ url_event_dashboard = reverse('dashboard:dashboard_event', kwargs={"slug": self.event.slug}) if apps.is_installed('AKSubmission'): @@ -95,6 +122,9 @@ class DashboardTests(TestCase): self.assertContains(response, "AK Submission") def test_plan_hidden(self): + """ + Test visibility of plan buttons with regard to plan visibility status for a given event + """ url_event_dashboard = reverse('dashboard:dashboard_event', kwargs={"slug": self.event.slug}) if apps.is_installed('AKPlan'): @@ -114,6 +144,9 @@ class DashboardTests(TestCase): self.assertContains(response, "AK Wall") def test_dashboard_buttons(self): + """ + Make sure manually added buttons are displayed correctly + """ url_event_dashboard = reverse('dashboard:dashboard_event', kwargs={"slug": self.event.slug}) response = self.client.get(url_event_dashboard) @@ -129,6 +162,9 @@ class DashboardTests(TestCase): class DashboardViewTests(BasicViewTests, TestCase): + """ + Generic view tests, based on :class:`AKModel.BasicViewTests` as specified in this class in VIEWS + """ fixtures = ['model.json', 'dashboard.json'] APP_NAME = 'dashboard' diff --git a/AKDashboard/views.py b/AKDashboard/views.py index 88e3443e3f4c4378f52ff63b002186176d1fe546..601edda274cf0a5d893571d6307d001c49affb63 100644 --- a/AKDashboard/views.py +++ b/AKDashboard/views.py @@ -1,5 +1,4 @@ from django.apps import apps -from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import TemplateView, DetailView @@ -10,6 +9,11 @@ from AKPlanning import settings class DashboardView(TemplateView): + """ + Index view of dashboard and therefore the main entry point for AKPlanning + + Displays information and buttons for all public events + """ template_name = 'AKDashboard/dashboard.html' @method_decorator(ensure_csrf_cookie) @@ -23,6 +27,14 @@ class DashboardView(TemplateView): class DashboardEventView(DetailView): + """ + Dashboard view for a single event + + In addition to the basic information and the buttons, + an overview over recent events (new and changed AKs, moved AKSlots) for the given event is shown. + + The event dashboard also exists for non-public events (one only needs to know the URL/slug of the event). + """ template_name = 'AKDashboard/dashboard_event.html' context_object_name = 'event' model = Event @@ -32,11 +44,16 @@ class DashboardEventView(DetailView): # Show feed of recent changes (if activated) if settings.DASHBOARD_SHOW_RECENT: + # Create a list of chronically sorted events (both AK and plan changes): recent_changes = [] - # Newest AKs + # Newest AKs (if AKSubmission is used) if apps.is_installed("AKSubmission"): - submission_changes = AK.history.filter(event=context['event'])[:int(settings.DASHBOARD_RECENT_MAX)] + # Get the latest x changes (if there are that many), + # where x corresponds to the entry threshold configured in the settings + # (such that the list will be completely filled even if there are no (newer) plan changes) + submission_changes = AK.history.filter(event=context['event'])[:int(settings.DASHBOARD_RECENT_MAX)] # pylint: disable=no-member, line-too-long + # Create textual representation including icons for s in submission_changes: if s.history_type == '+': text = _('New AK: %(ak)s.') % {'ak': s.name} @@ -48,18 +65,21 @@ class DashboardEventView(DetailView): text = _('AK "%(ak)s" deleted.') % {'ak': s.name} icon = ('times', 'fas') - recent_changes.append({'icon': icon, 'text': text, 'link': s.instance.detail_url, 'timestamp': s.history_date}) - - # Changes in plan - if apps.is_installed("AKPlan"): - if not context['event'].plan_hidden: - last_changed_slots = AKSlot.objects.select_related('ak').filter(event=context['event'], start__isnull=False).order_by('-updated')[ - :int(settings.DASHBOARD_RECENT_MAX)] - for changed_slot in last_changed_slots: - recent_changes.append({'icon': ('clock', 'far'), - 'text': _('AK "%(ak)s" (re-)scheduled.') % {'ak': changed_slot.ak.name}, - 'link': changed_slot.ak.detail_url, - 'timestamp': changed_slot.updated}) + # Store representation in change list (still unsorted) + recent_changes.append( + {'icon': icon, 'text': text, 'link': s.instance.detail_url, 'timestamp': s.history_date} + ) + + # Changes in plan (if AKPlan is used and plan is publicly visible) + if apps.is_installed("AKPlan") and not context['event'].plan_hidden: + # Get the latest plan changes (again using a threshold, see above) + last_changed_slots = AKSlot.objects.select_related('ak').filter(event=context['event'], start__isnull=False).order_by('-updated')[:int(settings.DASHBOARD_RECENT_MAX)] #pylint: disable=line-too-long + for changed_slot in last_changed_slots: + # Create textual representation including icons and links and store in list (still unsorted) + recent_changes.append({'icon': ('clock', 'far'), + 'text': _('AK "%(ak)s" (re-)scheduled.') % {'ak': changed_slot.ak.name}, + 'link': changed_slot.ak.detail_url, + 'timestamp': changed_slot.updated}) # Sort by change date... recent_changes.sort(key=lambda x: x['timestamp'], reverse=True) diff --git a/AKModel/admin.py b/AKModel/admin.py index 0d48bc6f269a3a886f78da948f7c4d29df8e63c8..29c14a05bd85bc2d530b41bf4c179880ec4f6fbe 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 5af2af0d5ddb4c45cf30635743e869d3c32a62f1..8b90f381b68e372c89f84837374bda1ab9251fad 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 c6abc863cc06bf327bb44c19635c7dcd91e4c39b..994949a8ef98eadad4eef68b5a94c3abfdf9979b 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 4f90ddc2d3403f1c0052293b98efa26b3ddf5491..7ce794dcda52fcbde462584379e1447ad2b124f2 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 92b3adc61ee6eff6011bbe1a846fcaf89f5aaa51..ae7569db012d8ac1bb0a4f63de37b26eed08d5e6 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 a6536beecbdbb0945b73ad414744d15d8fc87bdb..b476f0d79509edc396ba04d3cc1fec02d5189128 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 606b57539cc406adc67cac537a739e9727d8c29f..f547566817eb55969906c63644119c7294848c80 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 f1f5756bdebe19854d5533ad9ac7b022f5aa1350..ac995f3afa67eb991aaeb41e9d6d8b65cfb378c8 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-16 16:30+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,67 +21,67 @@ 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:138 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:151 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:99 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:114 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:99 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:112 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:125 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:25 AKModel/availability/models.py:271 msgid "Availability" msgstr "Verfügbarkeit" -#: AKModel/availability/forms.py:23 +#: AKModel/availability/forms.py:27 msgid "" "Click and drag to mark the availability during the event, double-click to " "delete. Or use the start and end inputs to add entries to the calendar view." @@ -90,121 +90,121 @@ msgstr "" "Doppelt klicken um Einträge zu löschen. Oder Start- und End-Eingabe " "verwenden, um der Kalenderansicht neue Einträge hinzuzufügen." -#: AKModel/availability/forms.py:88 +#: AKModel/availability/forms.py:123 msgid "The submitted availability does not comply with the required format." msgstr "Die eingetragenen Verfügbarkeit haben nicht das notwendige Format." -#: AKModel/availability/forms.py:101 +#: AKModel/availability/forms.py:137 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:162 AKModel/availability/forms.py:172 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:43 AKModel/models.py:58 AKModel/models.py:172 +#: AKModel/models.py:249 AKModel/models.py:268 AKModel/models.py:294 +#: AKModel/models.py:348 AKModel/models.py:475 AKModel/models.py:514 +#: AKModel/models.py:596 AKModel/models.py:651 AKModel/models.py:842 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:44 AKModel/models.py:173 +#: AKModel/models.py:250 AKModel/models.py:269 AKModel/models.py:295 +#: AKModel/models.py:349 AKModel/models.py:476 AKModel/models.py:515 +#: AKModel/models.py:597 AKModel/models.py:652 AKModel/models.py:843 msgid "Associated event" msgstr "Zugehöriges Event" -#: AKModel/availability/models.py:47 +#: AKModel/availability/models.py:52 msgid "Person" msgstr "Person" -#: AKModel/availability/models.py:48 +#: AKModel/availability/models.py:53 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:61 AKModel/models.py:479 +#: AKModel/models.py:504 AKModel/models.py:661 msgid "Room" msgstr "Raum" -#: AKModel/availability/models.py:57 +#: AKModel/availability/models.py:62 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:70 AKModel/models.py:357 +#: AKModel/models.py:503 AKModel/models.py:591 msgid "AK" msgstr "AK" -#: AKModel/availability/models.py:66 +#: AKModel/availability/models.py:71 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:79 AKModel/models.py:253 +#: AKModel/models.py:667 msgid "AK Category" msgstr "AK-Kategorie" -#: AKModel/availability/models.py:75 +#: AKModel/availability/models.py:80 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:272 msgid "Availabilities" msgstr "Verfügbarkeiten" -#: AKModel/forms.py:43 +#: AKModel/forms.py:69 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:70 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:84 msgid "Copy ak categories" msgstr "AK-Kategorien kopieren" -#: AKModel/forms.py:59 +#: AKModel/forms.py:91 msgid "Copy ak requirements" msgstr "AK-Anforderungen kopieren" -#: AKModel/forms.py:80 +#: AKModel/forms.py:115 msgid "Copy dashboard buttons" msgstr "Dashboard-Buttons kopieren" -#: AKModel/forms.py:104 +#: AKModel/forms.py:156 msgid "# next AKs" msgstr "# nächste AKs" -#: AKModel/forms.py:105 +#: AKModel/forms.py:157 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:160 msgid "Presentation only?" msgstr "Nur Vorstellung?" -#: AKModel/forms.py:110 AKModel/forms.py:117 +#: AKModel/forms.py:162 AKModel/forms.py:169 msgid "Yes" msgstr "Ja" -#: AKModel/forms.py:110 AKModel/forms.py:117 +#: AKModel/forms.py:162 AKModel/forms.py:169 msgid "No" msgstr "Nein" -#: AKModel/forms.py:112 +#: AKModel/forms.py:164 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:167 msgid "Space for notes in wishes?" msgstr "Platz für Notizen bei den Wünschen?" -#: AKModel/forms.py:119 +#: AKModel/forms.py:171 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:180 AKModel/models.py:836 msgid "Default Slots" msgstr "Standardslots" -#: AKModel/forms.py:127 +#: AKModel/forms.py:182 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:198 msgid "New rooms" msgstr "Neue Räume" -#: AKModel/forms.py:138 +#: AKModel/forms.py:199 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:205 msgid "Default availabilities?" msgstr "Standardverfügbarkeiten?" -#: AKModel/forms.py:145 +#: AKModel/forms.py:206 msgid "Create default availabilities for all rooms?" msgstr "Standardverfügbarkeiten für alle Räume anlegen?" -#: AKModel/forms.py:154 +#: AKModel/forms.py:222 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:156 AKModel/models.py:29 msgid "Start" msgstr "Start" -#: AKModel/metaviews/admin.py:98 +#: AKModel/metaviews/admin.py:157 msgid "Settings" msgstr "Einstellungen" -#: AKModel/metaviews/admin.py:99 +#: AKModel/metaviews/admin.py:158 msgid "Event created, Prepare Import" msgstr "Event angelegt, Import vorbereiten" -#: AKModel/metaviews/admin.py:100 +#: AKModel/metaviews/admin.py:159 msgid "Import categories & requirements" msgstr "Kategorien & Anforderungen kopieren" -#: AKModel/metaviews/admin.py:101 +#: AKModel/metaviews/admin.py:160 msgid "Activate?" msgstr "Aktivieren?" -#: AKModel/metaviews/admin.py:102 +#: AKModel/metaviews/admin.py:161 #: 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:241 AKModel/models.py:265 +#: AKModel/models.py:292 AKModel/models.py:310 AKModel/models.py:467 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:167 msgid "Nickname" msgstr "Spitzname" -#: AKModel/models.py:124 +#: AKModel/models.py:167 msgid "Name to identify an AK owner by" msgstr "Name, durch den eine AK-Leitung identifiziert wird" -#: AKModel/models.py:125 +#: AKModel/models.py:168 msgid "Slug" msgstr "Slug" -#: AKModel/models.py:125 +#: AKModel/models.py:168 msgid "Slug for URL generation" msgstr "Slug für URL-Generierung" -#: AKModel/models.py:126 +#: AKModel/models.py:169 msgid "Institution" msgstr "Instutution" -#: AKModel/models.py:126 +#: AKModel/models.py:169 msgid "Uni etc." msgstr "Universität o.ä." -#: AKModel/models.py:127 AKModel/models.py:249 +#: AKModel/models.py:170 AKModel/models.py:319 msgid "Web Link" msgstr "Internet Link" -#: AKModel/models.py:127 +#: AKModel/models.py:170 msgid "Link to Homepage" msgstr "Link zu Homepage oder Webseite" -#: AKModel/models.py:133 AKModel/models.py:507 +#: AKModel/models.py:176 AKModel/models.py:660 msgid "AK Owner" msgstr "AK-Leitung" -#: AKModel/models.py:134 +#: AKModel/models.py:177 msgid "AK Owners" msgstr "AK-Leitungen" -#: AKModel/models.py:176 +#: AKModel/models.py:241 msgid "Name of the AK Category" msgstr "Name der AK-Kategorie" -#: AKModel/models.py:177 AKModel/models.py:201 +#: AKModel/models.py:242 AKModel/models.py:266 msgid "Color" msgstr "Farbe" -#: AKModel/models.py:177 AKModel/models.py:201 +#: AKModel/models.py:242 AKModel/models.py:266 msgid "Color for displaying" msgstr "Farbe für die Anzeige" -#: AKModel/models.py:178 AKModel/models.py:243 +#: AKModel/models.py:243 AKModel/models.py:313 msgid "Description" msgstr "Beschreibung" -#: AKModel/models.py:179 +#: AKModel/models.py:244 msgid "Short description of this AK Category" msgstr "Beschreibung der AK-Kategorie" -#: AKModel/models.py:180 +#: AKModel/models.py:245 msgid "Present by default" msgstr "Defaultmäßig präsentieren" -#: AKModel/models.py:182 +#: AKModel/models.py:246 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:254 msgid "AK Categories" msgstr "AK-Kategorien" -#: AKModel/models.py:200 +#: AKModel/models.py:265 msgid "Name of the AK Track" msgstr "Name des AK-Tracks" -#: AKModel/models.py:207 +#: AKModel/models.py:272 msgid "AK Track" msgstr "AK-Track" -#: AKModel/models.py:208 +#: AKModel/models.py:273 msgid "AK Tracks" msgstr "AK-Tracks" -#: AKModel/models.py:222 +#: AKModel/models.py:292 msgid "Name of the Requirement" msgstr "Name der Anforderung" -#: AKModel/models.py:228 AKModel/models.py:511 +#: AKModel/models.py:298 AKModel/models.py:664 msgid "AK Requirement" msgstr "AK-Anforderung" -#: AKModel/models.py:229 +#: AKModel/models.py:299 msgid "AK Requirements" msgstr "AK-Anforderungen" -#: AKModel/models.py:240 +#: AKModel/models.py:310 msgid "Name of the AK" msgstr "Name des AKs" -#: AKModel/models.py:241 +#: AKModel/models.py:311 msgid "Short Name" msgstr "Kurzer Name" -#: AKModel/models.py:242 +#: AKModel/models.py:312 msgid "Name displayed in the schedule" msgstr "Name zur Anzeige im AK-Plan" -#: AKModel/models.py:243 +#: AKModel/models.py:313 msgid "Description of the AK" msgstr "Beschreibung des AKs" -#: AKModel/models.py:245 +#: AKModel/models.py:315 msgid "Owners" msgstr "Leitungen" -#: AKModel/models.py:246 +#: AKModel/models.py:316 msgid "Those organizing the AK" msgstr "Menschen, die den AK organisieren und halten" -#: AKModel/models.py:249 +#: AKModel/models.py:319 msgid "Link to wiki page" msgstr "Link zur Wiki Seite" -#: AKModel/models.py:250 +#: AKModel/models.py:320 msgid "Protocol Link" msgstr "Protokolllink" -#: AKModel/models.py:250 +#: AKModel/models.py:320 msgid "Link to protocol" msgstr "Link zum Protokoll" -#: AKModel/models.py:252 +#: AKModel/models.py:322 msgid "Category" msgstr "Kategorie" -#: AKModel/models.py:253 +#: AKModel/models.py:323 msgid "Category of the AK" msgstr "Kategorie des AKs" -#: AKModel/models.py:254 +#: AKModel/models.py:324 msgid "Track" msgstr "Track" -#: AKModel/models.py:255 +#: AKModel/models.py:325 msgid "Track the AK belongs to" msgstr "Track zu dem der AK gehört" -#: AKModel/models.py:257 +#: AKModel/models.py:327 msgid "Resolution Intention" msgstr "Resolutionsabsicht" -#: AKModel/models.py:258 +#: AKModel/models.py:328 msgid "Intends to submit a resolution" msgstr "Beabsichtigt eine Resolution einzureichen" -#: AKModel/models.py:259 +#: AKModel/models.py:329 msgid "Present this AK" msgstr "AK präsentieren" -#: AKModel/models.py:260 +#: AKModel/models.py:330 msgid "Present results of this AK" msgstr "Die Ergebnisse dieses AKs vorstellen" -#: AKModel/models.py:262 AKModel/views/status.py:136 +#: AKModel/models.py:332 AKModel/views/status.py:168 msgid "Requirements" msgstr "Anforderungen" -#: AKModel/models.py:263 +#: AKModel/models.py:333 msgid "AK's Requirements" msgstr "Anforderungen des AKs" -#: AKModel/models.py:265 +#: AKModel/models.py:335 msgid "Conflicting AKs" msgstr "AK-Konflikte" -#: AKModel/models.py:266 +#: AKModel/models.py:336 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:337 msgid "Prerequisite AKs" msgstr "Vorausgesetzte AKs" -#: AKModel/models.py:268 +#: AKModel/models.py:338 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:340 msgid "Organizational Notes" msgstr "Notizen zur Organisation" -#: AKModel/models.py:271 +#: AKModel/models.py:341 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:344 msgid "Interest" msgstr "Interesse" -#: AKModel/models.py:273 +#: AKModel/models.py:344 msgid "Expected number of people" msgstr "Erwartete Personenzahl" -#: AKModel/models.py:274 +#: AKModel/models.py:345 msgid "Interest Counter" msgstr "Interessenszähler" -#: AKModel/models.py:275 +#: AKModel/models.py:346 msgid "People who have indicated interest online" msgstr "Anzahl Personen, die online Interesse bekundet haben" -#: AKModel/models.py:280 +#: AKModel/models.py:351 msgid "Export?" msgstr "Export?" -#: AKModel/models.py:281 +#: AKModel/models.py:352 msgid "Include AK in wiki export?" msgstr "AK bei Wiki-Export berücksichtigen?" -#: AKModel/models.py:287 AKModel/models.py:502 +#: AKModel/models.py:358 AKModel/models.py:655 #: AKModel/templates/admin/AKModel/status/event_aks.html:10 -#: AKModel/views/manage.py:55 AKModel/views/status.py:74 +#: AKModel/views/manage.py:73 AKModel/views/status.py:98 msgid "AKs" msgstr "AKs" -#: AKModel/models.py:346 +#: AKModel/models.py:467 msgid "Name or number of the room" msgstr "Name oder Nummer des Raums" -#: AKModel/models.py:347 +#: AKModel/models.py:468 msgid "Location" msgstr "Ort" -#: AKModel/models.py:348 +#: AKModel/models.py:469 msgid "Name or number of the location" msgstr "Name oder Nummer des Ortes" -#: AKModel/models.py:349 +#: AKModel/models.py:470 msgid "Capacity" msgstr "Kapazität" -#: AKModel/models.py:350 +#: AKModel/models.py:471 msgid "Maximum number of people (-1 for unlimited)." msgstr "Maximale Personenzahl (-1 wenn unbeschränkt)." -#: AKModel/models.py:351 +#: AKModel/models.py:472 msgid "Properties" msgstr "Eigenschaften" -#: AKModel/models.py:352 +#: AKModel/models.py:473 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:480 AKModel/views/status.py:60 msgid "Rooms" msgstr "Räume" -#: AKModel/models.py:376 +#: AKModel/models.py:503 msgid "AK being mapped" msgstr "AK, der zugeordnet wird" -#: AKModel/models.py:378 +#: AKModel/models.py:505 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:506 AKModel/models.py:839 msgid "Slot Begin" msgstr "Beginn des Slots" -#: AKModel/models.py:379 AKModel/models.py:661 +#: AKModel/models.py:506 AKModel/models.py:839 msgid "Time and date the slot begins" msgstr "Zeit und Datum zu der der AK beginnt" -#: AKModel/models.py:381 +#: AKModel/models.py:508 msgid "Duration" msgstr "Dauer" -#: AKModel/models.py:382 +#: AKModel/models.py:509 msgid "Length in hours" msgstr "Länge in Stunden" -#: AKModel/models.py:384 +#: AKModel/models.py:511 msgid "Scheduling fixed" msgstr "Planung fix" -#: AKModel/models.py:385 +#: AKModel/models.py:512 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:517 msgid "Last update" msgstr "Letzte Aktualisierung" -#: AKModel/models.py:393 +#: AKModel/models.py:520 msgid "AK Slot" msgstr "AK-Slot" -#: AKModel/models.py:394 AKModel/models.py:504 +#: AKModel/models.py:521 AKModel/models.py:657 msgid "AK Slots" msgstr "AK-Slot" -#: AKModel/models.py:416 AKModel/models.py:425 +#: AKModel/models.py:543 AKModel/models.py:552 msgid "Not scheduled yet" msgstr "Noch nicht geplant" -#: AKModel/models.py:454 +#: AKModel/models.py:592 msgid "AK this message belongs to" msgstr "AK zu dem die Nachricht gehört" -#: AKModel/models.py:455 +#: AKModel/models.py:593 msgid "Message text" msgstr "Nachrichtentext" -#: AKModel/models.py:456 +#: AKModel/models.py:594 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:600 msgid "AK Orga Message" msgstr "AK-Organachricht" -#: AKModel/models.py:463 +#: AKModel/models.py:601 msgid "AK Orga Messages" msgstr "AK-Organachrichten" -#: AKModel/models.py:472 +#: AKModel/models.py:618 msgid "Constraint Violation" msgstr "Constraintverletzung" -#: AKModel/models.py:473 AKModel/views/status.py:93 +#: AKModel/models.py:619 AKModel/views/status.py:117 msgid "Constraint Violations" msgstr "Constraintverletzungen" -#: AKModel/models.py:477 +#: AKModel/models.py:626 msgid "Owner has two parallel slots" msgstr "Leitung hat zwei Slots parallel" -#: AKModel/models.py:478 +#: AKModel/models.py:627 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:628 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:629 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:630 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:631 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:633 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:634 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:635 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:636 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:637 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:643 msgid "Warning" msgstr "Warnung" -#: AKModel/models.py:492 +#: AKModel/models.py:644 msgid "Violation" msgstr "Verletzung" -#: AKModel/models.py:494 +#: AKModel/models.py:646 msgid "Type" msgstr "Art" -#: AKModel/models.py:495 +#: AKModel/models.py:647 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:648 msgid "Level" msgstr "Level" -#: AKModel/models.py:497 +#: AKModel/models.py:649 msgid "Severity level of the violation" msgstr "Schweregrad der Verletzung" -#: AKModel/models.py:503 +#: AKModel/models.py:656 msgid "AK(s) belonging to this constraint" msgstr "AK(s), die zu diesem Constraint gehören" -#: AKModel/models.py:505 +#: AKModel/models.py:658 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:660 msgid "AK Owner belonging to this constraint" msgstr "AK Leitung(en), die zu diesem Constraint gehören" -#: AKModel/models.py:509 +#: AKModel/models.py:662 msgid "Room belonging to this constraint" msgstr "Raum, der zu diesem Constraint gehört" -#: AKModel/models.py:512 +#: AKModel/models.py:665 msgid "AK Requirement belonging to this constraint" msgstr "AK Anforderung, die zu diesem Constraint gehört" -#: AKModel/models.py:514 +#: AKModel/models.py:667 msgid "AK Category belonging to this constraint" msgstr "AK Kategorie, di zu diesem Constraint gehört" -#: AKModel/models.py:516 +#: AKModel/models.py:669 msgid "Comment" msgstr "Kommentar" -#: AKModel/models.py:516 +#: AKModel/models.py:669 msgid "Comment or further details for this violation" msgstr "Kommentar oder weitere Details zu dieser Vereletzung" -#: AKModel/models.py:519 +#: AKModel/models.py:672 msgid "Timestamp" msgstr "Timestamp" -#: AKModel/models.py:519 +#: AKModel/models.py:672 msgid "Time of creation" msgstr "Zeitpunkt der ERstellung" -#: AKModel/models.py:520 +#: AKModel/models.py:673 msgid "Manually Resolved" msgstr "Manuell behoben" -#: AKModel/models.py:521 +#: AKModel/models.py:674 msgid "Mark this violation manually as resolved" msgstr "Markiere diese Verletzung manuell als behoben" -#: AKModel/models.py:548 +#: AKModel/models.py:701 #: AKModel/templates/admin/AKModel/requirements_overview.html:27 msgid "Details" msgstr "Details" -#: AKModel/models.py:657 +#: AKModel/models.py:835 msgid "Default Slot" msgstr "Standardslot" -#: AKModel/models.py:662 +#: AKModel/models.py:840 msgid "Slot End" msgstr "Ende des Slots" -#: AKModel/models.py:662 +#: AKModel/models.py:840 msgid "Time and date the slot ends" msgstr "Zeit und Datum zu der der Slot endet" -#: AKModel/models.py:667 +#: AKModel/models.py:845 msgid "Primary categories" msgstr "Primäre Kategorien" -#: AKModel/models.py:668 +#: AKModel/models.py:846 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:14 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:184 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:109 msgid "Scheduling" msgstr "Scheduling" @@ -1101,204 +1101,204 @@ 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:53 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:89 msgid "AK Orga Messages successfully deleted" msgstr "AK-Organachrichten erfolgreich gelöscht" -#: AKModel/views/ak.py:82 +#: AKModel/views/ak.py:101 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:102 msgid "Reset of interest in AKs successful." msgstr "Interesse an AKs erfolgreich zurückgesetzt." -#: AKModel/views/ak.py:92 +#: AKModel/views/ak.py:116 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:117 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:104 #, python-format msgid "Copied '%(obj)s'" msgstr "'%(obj)s' kopiert" -#: AKModel/views/event_wizard.py:72 +#: AKModel/views/event_wizard.py:107 #, 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:35 AKModel/views/status.py:151 msgid "Export AK Slides" msgstr "AK-Folien exportieren" -#: AKModel/views/manage.py:36 +#: AKModel/views/manage.py:48 msgid "Symbols" msgstr "Symbole" -#: AKModel/views/manage.py:37 +#: AKModel/views/manage.py:49 msgid "Who?" msgstr "Wer?" -#: AKModel/views/manage.py:38 +#: AKModel/views/manage.py:50 msgid "Duration(s)" msgstr "Dauer(n)" -#: AKModel/views/manage.py:39 +#: AKModel/views/manage.py:51 msgid "Reso intention?" msgstr "Resolutionsabsicht?" -#: AKModel/views/manage.py:40 +#: AKModel/views/manage.py:52 msgid "Category (for Wishes)" msgstr "Kategorie (für Wünsche)" -#: AKModel/views/manage.py:77 +#: AKModel/views/manage.py:101 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:102 msgid "Constraint Violations marked as resolved" msgstr "Constraintverletzungen als manuell behoben markiert" -#: AKModel/views/manage.py:87 +#: AKModel/views/manage.py:114 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:115 msgid "Constraint Violations set to level 'violation'" msgstr "Constraintverletzungen auf Level \"Violation\" gesetzt" -#: AKModel/views/manage.py:97 +#: AKModel/views/manage.py:127 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:128 msgid "Constraint Violations set to level 'warning'" msgstr "Constraintverletzungen auf Level \"Warning\" gesetzt" -#: AKModel/views/manage.py:107 +#: AKModel/views/manage.py:140 msgid "Publish the plan(s) of:" msgstr "Den Plan/die Pläne veröffentlichen von:" -#: AKModel/views/manage.py:108 +#: AKModel/views/manage.py:141 msgid "Plan published" msgstr "Plan veröffentlicht" -#: AKModel/views/manage.py:117 +#: AKModel/views/manage.py:153 msgid "Unpublish the plan(s) of:" msgstr "Den Plan/die Pläne verbergen von:" -#: AKModel/views/manage.py:118 +#: AKModel/views/manage.py:154 msgid "Plan unpublished" msgstr "Plan verborgen" -#: AKModel/views/manage.py:127 AKModel/views/status.py:109 +#: AKModel/views/manage.py:166 AKModel/views/status.py:135 msgid "Edit Default Slots" msgstr "Standardslots bearbeiten" -#: AKModel/views/manage.py:164 +#: AKModel/views/manage.py:204 #, 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:235 #, 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:37 #, 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:51 AKModel/views/status.py:82 msgid "Import Rooms from CSV" msgstr "Räume aus CSV importieren" -#: AKModel/views/room.py:73 +#: AKModel/views/room.py:96 #, 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:101 #, python-brace-format msgid "Imported {count} room(s)" msgstr "{count} Raum/Räume importiert" -#: AKModel/views/room.py:79 +#: AKModel/views/room.py:103 msgid "No rooms imported" msgstr "Keine Räume importiert" -#: AKModel/views/status.py:14 +#: AKModel/views/status.py:17 msgid "Overview" msgstr "Ãœberblick" -#: AKModel/views/status.py:24 +#: AKModel/views/status.py:33 msgid "Categories" msgstr "Kategorien" -#: AKModel/views/status.py:28 +#: AKModel/views/status.py:37 msgid "Add category" msgstr "Kategorie hinzufügen" -#: AKModel/views/status.py:48 +#: AKModel/views/status.py:64 msgid "Add Room" msgstr "Raum hinzufügen" -#: AKModel/views/status.py:98 +#: AKModel/views/status.py:122 msgid "AKs requiring special attention" msgstr "AKs, die besondere Aufmerksamkeit benötigen" -#: AKModel/views/status.py:102 +#: AKModel/views/status.py:126 msgid "Enter Interest" msgstr "Interesse erfassen" -#: AKModel/views/status.py:113 +#: AKModel/views/status.py:139 msgid "Manage ak tracks" msgstr "AK-Tracks verwalten" -#: AKModel/views/status.py:117 +#: AKModel/views/status.py:143 msgid "Export AKs as CSV" msgstr "AKs als CSV exportieren" -#: AKModel/views/status.py:121 +#: AKModel/views/status.py:147 msgid "Export AKs for Wiki" msgstr "AKs im Wiki-Format exportieren" -#: AKModel/views/status.py:146 +#: AKModel/views/status.py:180 msgid "Show AKs for requirements" msgstr "Zu Anforderungen gehörige AKs anzeigen" -#: AKModel/views/status.py:157 +#: AKModel/views/status.py:194 msgid "Event Status" msgstr "Eventstatus" diff --git a/AKModel/management/commands/makemessages.py b/AKModel/management/commands/makemessages.py index d3e9149ef15610ba54f67e1629283e927e831cdd..bd9f89e1c936c2a97885076cf96b71b508744dca 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 f5a3ee6812b530245b2f052325432c6f85b23207..3956af084874c4d8d213804d325d271d33323fd8 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 9c6f90279875e267c67aae46183d6e50e749d9ce..fb0ff60685f7278a698423afad1296ebc3c71cef 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 2e1c3777d18f4b1e09f45e9bfad633eca338fe49..5579a745f3bfdba93440db868e35d497f7dc2ccb 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 3b1a9da9d3c5a6b4af98193e7b36eb73aaceb7a5..926b7e0e563678ea17eacf3021811e16e88367d5 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 8dbbb4eafa1c658f63de40d27986200e8de3552f..5d9ad60331fa78edef0231e50d2408637dbcba17 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 480689ef0d780732e59a560d0959e4ae03469680..bff4985023fb6e7e3b1e8d784dc081f36e5e8be9 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 06560dc7d056573ffed1d1825c560c99d513237e..9ca14812b3dd87d5a2d25ec5c66e50b9040b2919 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 d676992f93f21ef3d12fcdb7ba4556d0a34e6365..fb24dc08a7891425c24d6017be0a51e25566e52d 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,60 +88,83 @@ 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) + for view_name_info in self.VIEWS_STAFF_ONLY: + expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2] + view_name_with_prefix, url = self._name_and_url(view_name_info) response = self.client.get(url) - self.assertEqual(response.status_code, 302, msg=f"{view_name_with_prefix} ({url}) accessible by non-staff") + self.assertEqual(response.status_code, expected_response_code, + 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) + for view_name_info in self.VIEWS_STAFF_ONLY: + view_name_with_prefix, url = self._name_and_url(view_name_info) try: 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) + for view_name_info in self.VIEWS_STAFF_ONLY: + expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2] + view_name_with_prefix, url = self._name_and_url(view_name_info) response = self.client.get(url) - self.assertEqual(response.status_code, 302, + self.assertEqual(response.status_code, expected_response_code, 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 +197,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 +227,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 806178145462d507915f0d4c02d57462325d2255..763cc1a22ae73903242abcc2eefe7f2b4ee24209 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 640d398ee98119407035a4106df3f9ec0e1baf27..3afec5ac2de03a62ad3d103d17062927c3b97026 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 abf4c261e745b06e343388b47de68acba3f9da4b..06ef5abfd9c82195e584a0e9f66f0965e8c5a7fb 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 2aca36607af304fe6d21ea6c01eaa55fc081a736..76a401a135c6fa4b07f5091db145748743636799 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 369395d5504f26535bf126e068134262ea3a3a1e..6f01c10da3c4f33eeb8075d0f54c09f0dae54db9 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 adead3bac55062964d6db07c40023e45542c74f0..138a04a0ba9cb02db4afb86ebefda9b2e6e581bd 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 460c13afaf42ea3ab0b2bfd1f7577f2070182625..11173d972caf64c8b7a4e35c531dd9845f2eab3e 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" diff --git a/AKOnline/admin.py b/AKOnline/admin.py index 69f4bed71466a3d8fa84341a3c6fedf05ccb8428..811368858c4c7e4e8d85caa72048ea504c2fe6c8 100644 --- a/AKOnline/admin.py +++ b/AKOnline/admin.py @@ -5,6 +5,9 @@ from AKOnline.models import VirtualRoom @admin.register(VirtualRoom) class VirtualRoomAdmin(admin.ModelAdmin): + """ + Admin interface for virtual room model + """ model = VirtualRoom list_display = ['room', 'event', 'url'] list_filter = ['room__event'] diff --git a/AKOnline/apps.py b/AKOnline/apps.py index 16078585764016d5c291069a30b9f90022a89e16..6d73f96bf131847b1993a49bb8030ad45da0080b 100644 --- a/AKOnline/apps.py +++ b/AKOnline/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class AkonlineConfig(AppConfig): + """ + App configuration (default -- only to set the app name) + """ name = 'AKOnline' diff --git a/AKOnline/forms.py b/AKOnline/forms.py index bf33b9c4eb3366fb9ea61fe0f2d1867bfa96277f..379ad20b2f87ec917559e370c23cd2bcf4037884 100644 --- a/AKOnline/forms.py +++ b/AKOnline/forms.py @@ -6,23 +6,38 @@ from AKOnline.models import VirtualRoom class VirtualRoomForm(ModelForm): + """ + Form to create a virtual room + + Should be used as part of a multi form (see :class:`RoomWithVirtualForm` below) + """ class Meta: model = VirtualRoom - exclude = ['room'] + # Show all fields except for room + exclude = ['room'] #pylint: disable=modelform-uses-exclude def __init__(self, *args, **kwargs): - super(VirtualRoomForm, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + # Make the URL field optional to allow submitting the multi form without creating a virtual room self.fields['url'].required = False class RoomWithVirtualForm(MultiModelForm): + """ + Combined form to create rooms and optionally virtual rooms + + Multi-Form that combines a :class:`RoomForm` (from AKModel) and a :class:`VirtualRoomForm` (see above). + + The form will always create a room on valid input + and may additionally create a virtual room if the url field of the virtual room form part is set. + """ form_classes = { 'room': RoomForm, 'virtual': VirtualRoomForm } def save(self, commit=True): - objects = super(RoomWithVirtualForm, self).save(commit=False) + objects = super().save(commit=False) if commit: room = objects['room'] diff --git a/AKOnline/locale/de_DE/LC_MESSAGES/django.po b/AKOnline/locale/de_DE/LC_MESSAGES/django.po index cf4c442eb4ba431a086fe3c0f9ad748c628ed3f7..142c835d30cdf52c33b56dcd47626f6d534815ea 100644 --- a/AKOnline/locale/de_DE/LC_MESSAGES/django.po +++ b/AKOnline/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-03-26 19:51+0200\n" +"POT-Creation-Date: 2023-08-16 16:30+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" @@ -34,7 +34,7 @@ msgstr "Raum" msgid "Virtual Room" msgstr "Virtueller Raum" -#: AKOnline/models.py:17 AKOnline/views.py:27 +#: AKOnline/models.py:17 AKOnline/views.py:38 msgid "Virtual Rooms" msgstr "Virtuelle Räume" @@ -42,12 +42,12 @@ msgstr "Virtuelle Räume" msgid "Leave empty if that room is not virtual/hybrid." msgstr "Leer lassen wenn der Raum nicht virtuell/hybrid ist" -#: AKOnline/views.py:18 +#: AKOnline/views.py:25 #, python-format msgid "Created Room '%(room)s'" msgstr "Raum '%(room)s' angelegt" -#: AKOnline/views.py:20 +#: AKOnline/views.py:28 #, python-format msgid "Created related Virtual Room '%(vroom)s'" msgstr "Verbundenen virtuellen Raum '%(vroom)s' angelegt" diff --git a/AKOnline/models.py b/AKOnline/models.py index dad360cb42df6b602a99d9366b514a0d5ee5d912..6740d3d51326009512f22bec15d71d4a59df5111 100644 --- a/AKOnline/models.py +++ b/AKOnline/models.py @@ -1,7 +1,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from AKModel.models import Event, Room +from AKModel.models import Room class VirtualRoom(models.Model): @@ -18,6 +18,12 @@ class VirtualRoom(models.Model): @property def event(self): + """ + Property: Event this virtual room belongs to. + + :return: Event this virtual room belongs to + :rtype: Event + """ return self.room.event def __str__(self): diff --git a/AKOnline/tests.py b/AKOnline/tests.py deleted file mode 100644 index a39b155ac3ee946fb97efafe6ecbb42f571cd7ad..0000000000000000000000000000000000000000 --- a/AKOnline/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/AKOnline/views.py b/AKOnline/views.py index 13f089a893fd77e49f223eba9c4bd9d58f4242bb..ecd82a22219c82370950e30c1645a1b4b0eb099c 100644 --- a/AKOnline/views.py +++ b/AKOnline/views.py @@ -9,20 +9,31 @@ from AKOnline.forms import RoomWithVirtualForm class RoomCreationWithVirtualView(RoomCreationView): + """ + View to create both rooms and optionally virtual rooms by filling one form + """ form_class = RoomWithVirtualForm template_name = 'admin/AKOnline/room_create_with_virtual.html' + room = None def form_valid(self, form): + # This will create the room and additionally a virtual room if the url field is not blank + # objects['room'] will always a room instance afterwards, objects['virtual'] may be empty objects = form.save() self.room = objects['room'] - messages.success(self.request, _("Created Room '%(room)s'" % {'room': objects['room']})) + # Create a (translated) success message containing information about the created room + messages.success(self.request, _("Created Room '%(room)s'" % {'room': objects['room']})) #pylint: disable=consider-using-f-string, line-too-long if objects['virtual'] is not None: - messages.success(self.request, _("Created related Virtual Room '%(vroom)s'" % {'vroom': objects['virtual']})) + # Create a (translated) success message containing information about the created virtual room + messages.success(self.request, _("Created related Virtual Room '%(vroom)s'" % {'vroom': objects['virtual']})) #pylint: disable=consider-using-f-string, line-too-long return HttpResponseRedirect(self.get_success_url()) @status_manager.register(name="event_virtual_rooms") class EventVirtualRoomsWidget(TemplateStatusWidget): + """ + Status page widget to contain information about all virtual rooms belonging to the given event + """ required_context_type = "event" title = _("Virtual Rooms") template_name = "admin/AKOnline/status/event_virtual_rooms.html" diff --git a/AKPlan/admin.py b/AKPlan/admin.py deleted file mode 100644 index 846f6b4061a68eda58bc9c76c36603d1e7721ee8..0000000000000000000000000000000000000000 --- a/AKPlan/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/AKPlan/apps.py b/AKPlan/apps.py index f18da123bf48684c2fda835ceb479dadd24bf758..bf67895b111a417a6d3894c859bf406c82ded869 100644 --- a/AKPlan/apps.py +++ b/AKPlan/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class AkplanConfig(AppConfig): + """ + App configuration (default, only specifies name of the app) + """ name = 'AKPlan' diff --git a/AKPlan/models.py b/AKPlan/models.py deleted file mode 100644 index 6b2021999398416a78191ac543b7e0e34d86bc2c..0000000000000000000000000000000000000000 --- a/AKPlan/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/AKPlan/templatetags/color_gradients.py b/AKPlan/templatetags/color_gradients.py index 1a9f96fd87eda8e405894eef648e048d81e87d32..2574efbcb425752fb35d6159ac3bb59a7df9f132 100644 --- a/AKPlan/templatetags/color_gradients.py +++ b/AKPlan/templatetags/color_gradients.py @@ -1,7 +1,7 @@ # gradients based on http://bsou.io/posts/color-gradients-with-python -def hex_to_rgb(hex): +def hex_to_rgb(hex): #pylint: disable=redefined-builtin """ Convert hex color to RGB color code :param hex: hex encoded color @@ -23,8 +23,7 @@ def rgb_to_hex(rgb): """ # Components need to be integers for hex to make sense rgb = [int(x) for x in rgb] - return "#"+"".join(["0{0:x}".format(v) if v < 16 else - "{0:x}".format(v) for v in rgb]) + return "#"+"".join([f"0{v:x}" if v < 16 else f"{v:x}" for v in rgb]) def linear_blend(start_hex, end_hex, position): diff --git a/AKPlan/templatetags/tags_AKPlan.py b/AKPlan/templatetags/tags_AKPlan.py index 8b30bfabd34b501a858351d6b1e384b4fe39e5bb..ae259be4cc042ede588f4b12867dfabfc37b15bf 100644 --- a/AKPlan/templatetags/tags_AKPlan.py +++ b/AKPlan/templatetags/tags_AKPlan.py @@ -11,6 +11,14 @@ register = template.Library() @register.filter def highlight_change_colors(akslot): + """ + Adjust color to highlight recent changes if needed + + :param akslot: akslot to determine color for + :type akslot: AKSlot + :return: color that should be used (either default color of the category or some kind of red) + :rtype: str + """ # Do not highlight in preview mode or when changes occurred before the plan was published if akslot.event.plan_hidden or (akslot.event.plan_published_at is not None and akslot.event.plan_published_at > akslot.updated): @@ -25,9 +33,14 @@ def highlight_change_colors(akslot): # Recent change? Calculate gradient blend between red and recentness = seconds_since_update / settings.PLAN_MAX_HIGHLIGHT_UPDATE_SECONDS return darken("#b71540", recentness) - # return linear_blend("#b71540", "#000000", recentness) @register.simple_tag def timestamp_now(tz): + """ + Get the current timestamp for the given timezone + + :param tz: timezone to be used for the timestamp + :return: current timestamp in given timezone + """ return date_format(datetime.now().astimezone(tz), "c") diff --git a/AKPlan/tests.py b/AKPlan/tests.py index e19157c076dcb497cf7585767fd048b89c3355ba..69365c2ba783311708a7babde1fda534c978b61c 100644 --- a/AKPlan/tests.py +++ b/AKPlan/tests.py @@ -4,6 +4,9 @@ from AKModel.tests import BasicViewTests class PlanViewTests(BasicViewTests, TestCase): + """ + Tests for AKPlan + """ fixtures = ['model.json'] APP_NAME = 'plan' @@ -15,7 +18,10 @@ class PlanViewTests(BasicViewTests, TestCase): ] def test_plan_hidden(self): - view_name_with_prefix, url = self._name_and_url(('plan_overview', {'event_slug': 'kif23'})) + """ + Test correct handling of plan visibility + """ + _, url = self._name_and_url(('plan_overview', {'event_slug': 'kif23'})) self.client.logout() response = self.client.get(url) @@ -28,8 +34,11 @@ class PlanViewTests(BasicViewTests, TestCase): msg_prefix="Plan is not visible for staff user") def test_wall_redirect(self): - view_name_with_prefix, url_wall = self._name_and_url(('plan_wall', {'event_slug': 'kif23'})) - view_name_with_prefix, url_plan = self._name_and_url(('plan_overview', {'event_slug': 'kif23'})) + """ + Test: Make sure that user is redirected from wall to overview when plan is hidden + """ + _, url_wall = self._name_and_url(('plan_wall', {'event_slug': 'kif23'})) + _, url_plan = self._name_and_url(('plan_overview', {'event_slug': 'kif23'})) response = self.client.get(url_wall) self.assertRedirects(response, url_plan, diff --git a/AKPlan/views.py b/AKPlan/views.py index 513bca31838a5f33da37b3127a579cdc2a7c500a..87c513a646dba58661a1a64c50d10121c148ba3b 100644 --- a/AKPlan/views.py +++ b/AKPlan/views.py @@ -1,5 +1,3 @@ -from datetime import timedelta - from django.conf import settings from django.shortcuts import redirect from django.urls import reverse_lazy @@ -11,6 +9,11 @@ from AKModel.metaviews.admin import FilterByEventSlugMixin class PlanIndexView(FilterByEventSlugMixin, ListView): + """ + Default plan view + + Shows two lists of current and upcoming AKs and a graphical full plan below + """ model = AKSlot template_name = "AKPlan/plan_index.html" context_object_name = "akslots" @@ -60,6 +63,15 @@ class PlanIndexView(FilterByEventSlugMixin, ListView): class PlanScreenView(PlanIndexView): + """ + Plan view optimized for screens and projectors + + This again shows current and upcoming AKs as well as a graphical plan, + but no navigation elements and trys to use the available space as best as possible + such that no scrolling is needed. + + The view contains a frontend functionality for auto-reload. + """ template_name = "AKPlan/plan_wall.html" def get(self, request, *args, **kwargs): @@ -92,22 +104,34 @@ class PlanScreenView(PlanIndexView): class PlanRoomView(FilterByEventSlugMixin, DetailView): + """ + Plan view for a single room + """ template_name = "AKPlan/plan_room.html" model = Room context_object_name = "room" def get_context_data(self, *, object_list=None, **kwargs): context = super().get_context_data(object_list=object_list, **kwargs) + # Restrict AKSlot list to the given room + # while joining AK, room and category information to reduce the amount of necessary SQL queries context["slots"] = AKSlot.objects.filter(room=context['room']).select_related('ak', 'ak__category', 'ak__track') return context class PlanTrackView(FilterByEventSlugMixin, DetailView): + """ + Plan view for a single track + """ template_name = "AKPlan/plan_track.html" model = AKTrack context_object_name = "track" def get_context_data(self, *, object_list=None, **kwargs): context = super().get_context_data(object_list=object_list, **kwargs) - context["slots"] = AKSlot.objects.filter(event=self.event, ak__track=context['track']).select_related('ak', 'room', 'ak__category') + # Restrict AKSlot list to given track + # while joining AK, room and category information to reduce the amount of necessary SQL queries + context["slots"] = AKSlot.objects.\ + filter(event=self.event, ak__track=context['track']).\ + select_related('ak', 'room', 'ak__category') return context diff --git a/AKPlanning/locale/de_DE/LC_MESSAGES/django.po b/AKPlanning/locale/de_DE/LC_MESSAGES/django.po index 1149811cf7c7b94b2fc5899f8c518463d376d448..3f47223fa6145f6d4c55f87c6f4c1c660f2bd311 100644 --- a/AKPlanning/locale/de_DE/LC_MESSAGES/django.po +++ b/AKPlanning/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-15 20:03+0200\n" +"POT-Creation-Date: 2023-08-16 16:30+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" @@ -17,10 +17,10 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: AKPlanning/settings.py:147 +#: AKPlanning/settings.py:148 msgid "German" msgstr "Deutsch" -#: AKPlanning/settings.py:148 +#: AKPlanning/settings.py:149 msgid "English" msgstr "Englisch" diff --git a/AKPlanning/settings.py b/AKPlanning/settings.py index c697b2e41bc98d2621edd8173b7ceb7155fbb438..3dc46aa57266a36bcdef18393ef3fd6571d3727b 100644 --- a/AKPlanning/settings.py +++ b/AKPlanning/settings.py @@ -55,6 +55,7 @@ INSTALLED_APPS = [ 'bootstrap_datepicker_plus', 'django_tex', 'compressor', + 'docs', ] MIDDLEWARE = [ @@ -234,4 +235,9 @@ CSP_FONT_SRC = ("'self'", "data:", "fonts.gstatic.com") SEND_MAILS = True EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +# Documentation + +DOCS_ROOT = os.path.join(BASE_DIR, 'docs/_build/html') +DOCS_ACCESS = 'public' + include(optional("settings/*.py")) diff --git a/AKPlanning/urls.py b/AKPlanning/urls.py index cdbbea559617339113a51427b7b138417680b582..c97f013422b9f23edb436c1bf44db327924cd48e 100644 --- a/AKPlanning/urls.py +++ b/AKPlanning/urls.py @@ -1,4 +1,5 @@ -"""AKPlanning URL Configuration +""" +AKPlanning URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/2.2/topics/http/urls/ @@ -13,13 +14,15 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + import debug_toolbar from django.apps import apps from django.contrib import admin -from django.urls import path, include +from django.urls import path, include, re_path urlpatterns = [ path('admin/', admin.site.urls), + re_path(r'^docs/', include('docs.urls')), path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('registration.backends.simple.urls')), path('', include('AKModel.urls', namespace='model')), diff --git a/AKScheduling/admin.py b/AKScheduling/admin.py deleted file mode 100644 index 846f6b4061a68eda58bc9c76c36603d1e7721ee8..0000000000000000000000000000000000000000 --- a/AKScheduling/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/AKScheduling/api.py b/AKScheduling/api.py index 7155e6fe6e47081ccc6101699eba02bab78274ec..61c939ede1526504409a90ff52bcd55f66c76302 100644 --- a/AKScheduling/api.py +++ b/AKScheduling/api.py @@ -12,20 +12,32 @@ from AKModel.metaviews.admin import EventSlugMixin class ResourceSerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for Rooms to produce format required for fullcalendar resources + """ class Meta: model = Room fields = ['id', 'title'] title = serializers.SerializerMethodField('transform_title') - def transform_title(self, obj): + @staticmethod + def transform_title(obj): + """ + Adapt title, add capacity information if room has a restriction (capacity is not -1) + """ if obj.capacity > 0: return f"{obj.title} [{obj.capacity}]" return obj.title -class ResourcesViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): - permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) +class ResourcesViewSet(EventSlugMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: Rooms (resources to schedule for in fullcalendar) + + Read-only, adaption to fullcalendar format through :class:`ResourceSerializer` + """ + permission_classes = (permissions.DjangoModelPermissions,) serializer_class = ResourceSerializer def get_queryset(self): @@ -33,6 +45,13 @@ class ResourcesViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListMod class EventsView(LoginRequiredMixin, EventSlugMixin, ListView): + """ + API View: Slots (events to schedule in fullcalendar) + + Read-only, JSON formatted response is created manually since it requires a bunch of "custom" fields that have + different names compared to the normal model or are not present at all and need to be computed to create the + required format for fullcalendar. + """ model = AKSlot def get_queryset(self): @@ -42,13 +61,16 @@ class EventsView(LoginRequiredMixin, EventSlugMixin, ListView): return JsonResponse( [{ "slotID": slot.pk, - "title": f'{slot.ak.short_name}: \n{slot.ak.owners_list}', + "title": f'{slot.ak.short_name}:\n{slot.ak.owners_list}', "description": slot.ak.details, "resourceId": slot.room.id, "start": timezone.localtime(slot.start, self.event.timezone).strftime("%Y-%m-%d %H:%M:%S"), "end": timezone.localtime(slot.end, self.event.timezone).strftime("%Y-%m-%d %H:%M:%S"), "backgroundColor": slot.ak.category.color, - "borderColor": "#2c3e50" if slot.fixed else '#e74c3c' if slot.constraintviolation_set.count() > 0 else slot.ak.category.color, + "borderColor": + "#2c3e50" if slot.fixed + else '#e74c3c' if slot.constraintviolation_set.count() > 0 + else slot.ak.category.color, "constraint": 'roomAvailable', "editable": not slot.fixed, 'url': str(reverse('admin:AKModel_akslot_change', args=[slot.pk])), @@ -59,6 +81,13 @@ class EventsView(LoginRequiredMixin, EventSlugMixin, ListView): class RoomAvailabilitiesView(LoginRequiredMixin, EventSlugMixin, ListView): + """ + API view: Availabilities of rooms + + Read-only, JSON formatted response is created manually since it requires a bunch of "custom" fields that have + different names compared to the normal model or are not present at all and need to be computed to create the + required format for fullcalendar. + """ model = Availability context_object_name = "availabilities" @@ -81,6 +110,13 @@ class RoomAvailabilitiesView(LoginRequiredMixin, EventSlugMixin, ListView): class DefaultSlotsView(LoginRequiredMixin, EventSlugMixin, ListView): + """ + API view: default slots + + Read-only, JSON formatted response is created manually since it requires a bunch of "custom" fields that have + different names compared to the normal model or are not present at all and need to be computed to create the + required format for fullcalendar. + """ model = DefaultSlot context_object_name = "default_slots" @@ -105,6 +141,9 @@ class DefaultSlotsView(LoginRequiredMixin, EventSlugMixin, ListView): class EventSerializer(serializers.ModelSerializer): + """ + REST framework serializer to adapt between AKSlot model and the event format of fullcalendar + """ class Meta: model = AKSlot fields = ['id', 'start', 'end', 'roomId'] @@ -114,17 +153,31 @@ class EventSerializer(serializers.ModelSerializer): roomId = serializers.IntegerField(source='room.pk') def update(self, instance, validated_data): + # Ignore timezone of input (treat it as timezone-less) and set the event timezone + # By working like this, the client does not need to know about timezones, since every timestamp it deals with + # has the timezone offsets already applied start = timezone.make_aware(timezone.make_naive(validated_data.get('start')), instance.event.timezone) end = timezone.make_aware(timezone.make_naive(validated_data.get('end')), instance.event.timezone) instance.start = start - instance.room = get_object_or_404(Room, pk=validated_data.get('room')["pk"]) + # Also, adapt from start & end format of fullcalendar to our start & duration model diff = end - start instance.duration = round(diff.days * 24 + (diff.seconds / 3600), 2) + + # Updated room if needed (pk changed -- otherwise, no need for an additional database lookup) + new_room_id = validated_data.get('room')["pk"] + if instance.room.pk != new_room_id: + instance.room = get_object_or_404(Room, pk=new_room_id) + instance.save() return instance -class EventsViewSet(EventSlugMixin, viewsets.ModelViewSet): +class EventsViewSet(EventSlugMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet): + """ + API view: Update scheduling of a slot (event in fullcalendar format) + + Write-only (will however reply with written values to PUT request) + """ permission_classes = (permissions.DjangoModelPermissions,) serializer_class = EventSerializer @@ -136,17 +189,26 @@ class EventsViewSet(EventSlugMixin, viewsets.ModelViewSet): class ConstraintViolationSerializer(serializers.ModelSerializer): + """ + REST Framework Serializer for constraint violations + """ class Meta: model = ConstraintViolation - fields = ['pk', 'type_display', 'aks', 'ak_slots', 'ak_owner', 'room', 'requirement', 'category', 'comment', 'timestamp_display', 'manually_resolved', 'level_display', 'details', 'edit_url'] + fields = ['pk', 'type_display', 'aks', 'ak_slots', 'ak_owner', 'room', 'requirement', 'category', 'comment', + 'timestamp_display', 'manually_resolved', 'level_display', 'details', 'edit_url'] + +class ConstraintViolationsViewSet(EventSlugMixin, mixins.ListModelMixin, viewsets.GenericViewSet): + """ + API View: Constraint Violations of an event -class ConstraintViolationsViewSet(EventSlugMixin, viewsets.ModelViewSet): + Read-only, fields and model selected in :class:`ConstraintViolationSerializer` + """ permission_classes = (permissions.DjangoModelPermissions,) serializer_class = ConstraintViolationSerializer - def get_object(self): - return get_object_or_404(ConstraintViolation, pk=self.kwargs["pk"]) - def get_queryset(self): - return ConstraintViolation.objects.select_related('event', 'room').prefetch_related('aks', 'ak_slots', 'ak_owner', 'requirement', 'category').filter(event=self.event).order_by('manually_resolved', '-type', '-timestamp') + # Optimize query to reduce database load + return (ConstraintViolation.objects.select_related('event', 'room') + .prefetch_related('aks', 'ak_slots', 'ak_owner', 'requirement', 'category') + .filter(event=self.event).order_by('manually_resolved', '-type', '-timestamp')) diff --git a/AKScheduling/apps.py b/AKScheduling/apps.py index c76ceac5ba250b7c429226de5f803871102554d8..13a065bfd3fb836dcdfa898c55f1252e79dd2cba 100644 --- a/AKScheduling/apps.py +++ b/AKScheduling/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class AkschedulingConfig(AppConfig): + """ + App configuration (default, only specifies name of the app) + """ name = 'AKScheduling' diff --git a/AKScheduling/forms.py b/AKScheduling/forms.py index d1739eba90c8c3b2c1af2b24c0284de4c0672d62..47a19f7857c3c9e68d2c6e2b59cecf0d79fbcebf 100644 --- a/AKScheduling/forms.py +++ b/AKScheduling/forms.py @@ -1,9 +1,13 @@ from django import forms +from django.utils.translation import gettext_lazy as _ from AKModel.models import AK class AKInterestForm(forms.ModelForm): + """ + Form for quickly changing the interest count and notes of an AK + """ required_css_class = 'required' class Meta: @@ -11,3 +15,17 @@ class AKInterestForm(forms.ModelForm): fields = ['interest', 'notes', ] + + +class AKAddSlotForm(forms.Form): + """ + Form to create a new slot for an existing AK directly from scheduling view + """ + start = forms.CharField(label=_("Start"), disabled=True) + end = forms.CharField(label=_("End"), disabled=True) + duration = forms.CharField(label=_("Duration"), disabled=True) + room = forms.IntegerField(label=_("Room"), disabled=True) + + def __init__(self, event): + super().__init__() + self.fields['ak'] = forms.ModelChoiceField(event.ak_set.all(), label=_("AK")) diff --git a/AKScheduling/locale/de_DE/LC_MESSAGES/django.po b/AKScheduling/locale/de_DE/LC_MESSAGES/django.po index ec97a5ab0c8c48e0a28e7cd9d56a0188d30502a0..98bda60437d7408d8d716fc7f2fee6c994801e2d 100644 --- a/AKScheduling/locale/de_DE/LC_MESSAGES/django.po +++ b/AKScheduling/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-15 20:03+0200\n" +"POT-Creation-Date: 2023-08-16 16:30+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" @@ -17,7 +17,28 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: AKScheduling/models.py:80 +#: AKScheduling/forms.py:24 +msgid "Start" +msgstr "Start" + +#: AKScheduling/forms.py:25 +msgid "End" +msgstr "Ende" + +#: AKScheduling/forms.py:26 +msgid "Duration" +msgstr "" + +#: AKScheduling/forms.py:27 +#: AKScheduling/templates/admin/AKScheduling/scheduling.html:171 +msgid "Room" +msgstr "Raum" + +#: AKScheduling/forms.py:31 +msgid "AK" +msgstr "AK" + +#: AKScheduling/models.py:92 #, python-format msgid "" "Not enough space for AK interest (Interest: %(interest)d, Capacity: " @@ -26,7 +47,7 @@ msgstr "" "Nicht genug Platz für AK-Interesse (Interesse: %(interest)d, Kapazität: " "%(capacity)d)" -#: AKScheduling/models.py:92 +#: AKScheduling/models.py:106 #, python-format msgid "" "Space is too close to AK interest (Interest: %(interest)d, Capacity: " @@ -147,10 +168,6 @@ msgstr "Event (horizontal)" msgid "Event (Vertical)" msgstr "Event (vertikal)" -#: AKScheduling/templates/admin/AKScheduling/scheduling.html:171 -msgid "Room" -msgstr "Raum" - #: AKScheduling/templates/admin/AKScheduling/scheduling.html:267 msgid "Please choose AK" msgstr "Bitte AK auswählen" @@ -211,19 +228,19 @@ msgstr "Noch nicht geschedulte AK-Slots" msgid "Count" msgstr "Anzahl" -#: AKScheduling/views.py:112 +#: AKScheduling/views.py:148 msgid "Interest updated" msgstr "Interesse aktualisiert" -#: AKScheduling/views.py:157 +#: AKScheduling/views.py:199 msgid "Wishes" msgstr "Wünsche" -#: AKScheduling/views.py:165 +#: AKScheduling/views.py:217 msgid "Cleanup: Delete unscheduled slots for wishes" msgstr "Aufräumen: Noch nicht geplante Slots für Wünsche löschen" -#: AKScheduling/views.py:172 +#: AKScheduling/views.py:224 #, python-brace-format msgid "" "The following {count} unscheduled slots of wishes will be deleted:\n" @@ -235,15 +252,15 @@ msgstr "" "\n" " {slots}" -#: AKScheduling/views.py:179 +#: AKScheduling/views.py:231 msgid "Unscheduled slots for wishes successfully deleted" msgstr "Noch nicht geplante Slots für Wünsche erfolgreich gelöscht" -#: AKScheduling/views.py:184 +#: AKScheduling/views.py:245 msgid "Create default availabilities for AKs" msgstr "Standardverfügbarkeiten für AKs anlegen" -#: AKScheduling/views.py:191 +#: AKScheduling/views.py:252 #, python-brace-format msgid "" "The following {count} AKs don't have any availability information. Create " @@ -256,12 +273,12 @@ msgstr "" "\n" " {aks}" -#: AKScheduling/views.py:209 +#: AKScheduling/views.py:272 #, python-brace-format msgid "Could not create default availabilities for AK: {ak}" msgstr "Konnte keine Verfügbarkeit anlegen für AK: {ak}" -#: AKScheduling/views.py:214 +#: AKScheduling/views.py:277 #, python-brace-format msgid "Created default availabilities for {count} AKs" msgstr "Standardverfügbarkeiten für {count} AKs angelegt" diff --git a/AKScheduling/models.py b/AKScheduling/models.py index 6664a8aee83ddf108b04666a5e99a5fea077b4e7..769527f7aec7792c565e7a18bdbb888c938eeb59 100644 --- a/AKScheduling/models.py +++ b/AKScheduling/models.py @@ -1,9 +1,15 @@ +# This file mainly contains signal receivers, which follow a very strong interface, having e.g., a sender attribute +# that is hardly used by us. Nevertheless, to follow the django receiver coding style and since changes might +# cause issues when loading fixtures or model dumps, it is not wise to replace that attribute with "_". +# Therefore, the check that finds unused arguments is disabled for this whole file: +# pylint: disable=unused-argument + from django.db.models.signals import post_save, m2m_changed, pre_delete from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from AKModel.availability.models import Availability -from AKModel.models import AK, AKSlot, Room, Event, AKOwner, ConstraintViolation +from AKModel.models import AK, AKSlot, Room, Event, ConstraintViolation def update_constraint_violations(new_violations, existing_violations_to_check): @@ -43,11 +49,15 @@ def update_cv_reso_deadline_for_slot(slot): :type slot: AKSlot """ event = slot.event + + # Update only if reso_deadline exists + # if event was changed and reso_deadline is removed, CVs will be deleted by event changed handler + # Update only has to be done for already scheduled slots with reso intention if slot.ak.reso and slot.event.reso_deadline and slot.start: - # Update only if reso_deadline exists - # if event was changed and reso_deadline is removed, CVs will be deleted by event changed handler violation_type = ConstraintViolation.ViolationType.AK_AFTER_RESODEADLINE new_violations = [] + + # Violation? if slot.end > event.reso_deadline: c = ConstraintViolation( type=violation_type, @@ -69,38 +79,47 @@ def check_capacity_for_slot(slot: AKSlot): :return: Violation (if any) or None :rtype: ConstraintViolation or None """ - if slot.room: - if slot.room.capacity >= 0: - if slot.room.capacity < slot.ak.interest: - c = ConstraintViolation( - type=ConstraintViolation.ViolationType.ROOM_CAPACITY_EXCEEDED, - level=ConstraintViolation.ViolationLevel.VIOLATION, - event=slot.event, - room=slot.room, - comment=_("Not enough space for AK interest (Interest: %(interest)d, Capacity: %(capacity)d)") - % {'interest': slot.ak.interest, 'capacity': slot.room.capacity}, - ) - c.ak_slots_tmp.add(slot) - c.aks_tmp.add(slot.ak) - return c - elif slot.room.capacity < slot.ak.interest + 5 or slot.room.capacity < slot.ak.interest * 1.25: - c = ConstraintViolation( - type=ConstraintViolation.ViolationType.ROOM_CAPACITY_EXCEEDED, - level=ConstraintViolation.ViolationLevel.WARNING, - event=slot.event, - room=slot.room, - comment=_("Space is too close to AK interest (Interest: %(interest)d, Capacity: %(capacity)d)") - % {'interest': slot.ak.interest, 'capacity': slot.room.capacity} - ) - c.ak_slots_tmp.add(slot) - c.aks_tmp.add(slot.ak) - return c - return None + + # If slot is scheduled in a room + if slot.room and slot.room.capacity >= 0: + # Create a violation if interest exceeds room capacity + if slot.room.capacity < slot.ak.interest: + c = ConstraintViolation( + type=ConstraintViolation.ViolationType.ROOM_CAPACITY_EXCEEDED, + level=ConstraintViolation.ViolationLevel.VIOLATION, + event=slot.event, + room=slot.room, + comment=_("Not enough space for AK interest (Interest: %(interest)d, Capacity: %(capacity)d)") + % {'interest': slot.ak.interest, 'capacity': slot.room.capacity}, + ) + c.ak_slots_tmp.add(slot) + c.aks_tmp.add(slot.ak) + return c + + # Create a warning if interest is close to room capacity + if slot.room.capacity < slot.ak.interest + 5 or slot.room.capacity < slot.ak.interest * 1.25: + c = ConstraintViolation( + type=ConstraintViolation.ViolationType.ROOM_CAPACITY_EXCEEDED, + level=ConstraintViolation.ViolationLevel.WARNING, + event=slot.event, + room=slot.room, + comment=_("Space is too close to AK interest (Interest: %(interest)d, Capacity: %(capacity)d)") + % {'interest': slot.ak.interest, 'capacity': slot.room.capacity} + ) + c.ak_slots_tmp.add(slot) + c.aks_tmp.add(slot.ak) + return c + + return None @receiver(post_save, sender=AK) def ak_changed_handler(sender, instance: AK, **kwargs): - # Changes might affect: Reso intention, Category, Interest + """ + Signal receiver: Check for violations after AK changed + + Changes might affect: Reso intention, Category, Interest + """ # TODO Reso intention changes # Check room capacities @@ -118,14 +137,12 @@ def ak_changed_handler(sender, instance: AK, **kwargs): @receiver(m2m_changed, sender=AK.owners.through) def ak_owners_changed_handler(sender, instance: AK, action: str, **kwargs): """ - Owners of AK changed + Signal receiver: Owners of AK changed """ # Only signal after change (post_add, post_delete, post_clear) are relevant if not action.startswith("post"): return - # print(f"{instance} changed") - event = instance.event # Owner(s) changed: Might affect multiple AKs by the same owner(s) at the same time @@ -157,8 +174,6 @@ def ak_owners_changed_handler(sender, instance: AK, action: str, **kwargs): c.ak_slots_tmp.add(other_slot) new_violations.append(c) - #print(f"{owner} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -169,14 +184,12 @@ def ak_owners_changed_handler(sender, instance: AK, action: str, **kwargs): @receiver(m2m_changed, sender=AK.conflicts.through) def ak_conflicts_changed_handler(sender, instance: AK, action: str, **kwargs): """ - Conflicts of AK changed + Signal receiver: Conflicts of AK changed """ # Only signal after change (post_add, post_delete, post_clear) are relevant if not action.startswith("post"): return - # print(f"{instance} changed") - event = instance.event # Conflict(s) changed: Might affect multiple AKs that are conflicts of each other @@ -186,6 +199,7 @@ def ak_conflicts_changed_handler(sender, instance: AK, action: str, **kwargs): slots_of_this_ak: [AKSlot] = instance.akslot_set.filter(start__isnull=False) conflicts_of_this_ak: [AK] = instance.conflicts.all() + # Loop over all existing conflicts for ak in conflicts_of_this_ak: if ak != instance: for other_slot in ak.akslot_set.filter(start__isnull=False): @@ -203,8 +217,6 @@ def ak_conflicts_changed_handler(sender, instance: AK, action: str, **kwargs): c.ak_slots_tmp.add(other_slot) new_violations.append(c) - # print(f"{instance} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -215,23 +227,22 @@ def ak_conflicts_changed_handler(sender, instance: AK, action: str, **kwargs): @receiver(m2m_changed, sender=AK.prerequisites.through) def ak_prerequisites_changed_handler(sender, instance: AK, action: str, **kwargs): """ - Prerequisites of AK changed + Signal receiver: Prerequisites of AK changed """ # Only signal after change (post_add, post_delete, post_clear) are relevant if not action.startswith("post"): return - # print(f"{instance} changed") - event = instance.event - # Conflict(s) changed: Might affect multiple AKs that are conflicts of each other + # Prerequisite(s) changed: Might affect multiple AKs that should have a certain order violation_type = ConstraintViolation.ViolationType.AK_BEFORE_PREREQUISITE new_violations = [] slots_of_this_ak: [AKSlot] = instance.akslot_set.filter(start__isnull=False) prerequisites_of_this_ak: [AK] = instance.prerequisites.all() + # Loop over all prerequisites for ak in prerequisites_of_this_ak: if ak != instance: for other_slot in ak.akslot_set.filter(start__isnull=False): @@ -249,8 +260,6 @@ def ak_prerequisites_changed_handler(sender, instance: AK, action: str, **kwargs c.ak_slots_tmp.add(other_slot) new_violations.append(c) - # print(f"{instance} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -261,14 +270,12 @@ def ak_prerequisites_changed_handler(sender, instance: AK, action: str, **kwargs @receiver(m2m_changed, sender=AK.requirements.through) def ak_requirements_changed_handler(sender, instance: AK, action: str, **kwargs): """ - Requirements of AK changed + Signal receiver: Requirements of AK changed """ # Only signal after change (post_add, post_delete, post_clear) are relevant if not action.startswith("post"): return - # print(f"{instance} changed") - event = instance.event # Requirement(s) changed: Might affect slots and rooms @@ -298,8 +305,6 @@ def ak_requirements_changed_handler(sender, instance: AK, action: str, **kwargs) c.ak_slots_tmp.add(slot) new_violations.append(c) - # print(f"{instance} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -309,8 +314,13 @@ def ak_requirements_changed_handler(sender, instance: AK, action: str, **kwargs) @receiver(post_save, sender=AKSlot) def akslot_changed_handler(sender, instance: AKSlot, **kwargs): - # Changes might affect: Duplicate parallel, Two in room, Resodeadline - # print(f"{sender} changed") + """ + Signal receiver: AKSlot changed + + Changes might affect: Duplicate parallel, Two in room, Resodeadline + """ + # TODO Consider rewriting this very long and complex method to resolve several (style) issues: + # pylint: disable=too-many-nested-blocks,too-many-locals,too-many-branches,too-many-statements event = instance.event # == Check for two parallel slots by one of the owners == @@ -341,8 +351,6 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): c.ak_slots_tmp.add(other_slot) new_violations.append(c) - # print(f"{owner} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -373,8 +381,6 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): c.ak_slots_tmp.add(other_slot) new_violations.append(c) - # print(f"Multiple slots in room {instance.room}: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the slot that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -437,8 +443,6 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): c.ak_slots_tmp.add(instance) new_violations.append(c) - # print(f"{instance.ak} has the following slots outside availabilities: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -470,8 +474,6 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): c.ak_slots_tmp.add(instance) new_violations.append(c) - # print(f"{instance} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) @@ -502,8 +504,6 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): c.ak_slots_tmp.add(other_slot) new_violations.append(c) - # print(f"{instance} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.ak.constraintviolation_set.filter(type=violation_type)) @@ -534,8 +534,6 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): c.ak_slots_tmp.add(other_slot) new_violations.append(c) - # print(f"{instance} has the following conflicts: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.ak.constraintviolation_set.filter(type=violation_type)) @@ -547,15 +545,21 @@ def akslot_changed_handler(sender, instance: AKSlot, **kwargs): new_violations = [cv] if cv is not None else [] # Compare to/update list of existing violations of this type for this slot - existing_violations_to_check = list(instance.constraintviolation_set.filter(type=ConstraintViolation.ViolationType.ROOM_CAPACITY_EXCEEDED)) + existing_violations_to_check = list( + instance.constraintviolation_set.filter(type=ConstraintViolation.ViolationType.ROOM_CAPACITY_EXCEEDED) + ) update_constraint_violations(new_violations, existing_violations_to_check) @receiver(pre_delete, sender=AKSlot) def akslot_deleted_handler(sender, instance: AKSlot, **kwargs): - # Manually clean up or remove constraint violations that belong to this slot since there is no cascade deletion - # for many2many relationships. Explicitly listening for AK deletion signals is not necessary since they will - # transitively trigger this signal and we always set both AK and AKSlot references in a constraint violation + """ + Signal receiver: AKSlot deleted + + Manually clean up or remove constraint violations that belong to this slot since there is no cascade deletion + for many2many relationships. Explicitly listening for AK deletion signals is not necessary since they will + transitively trigger this signal and we always set both AK and AKSlot references in a constraint violation + """ # print(f"{instance} deleted") for cv in instance.constraintviolation_set.all(): @@ -566,8 +570,11 @@ def akslot_deleted_handler(sender, instance: AKSlot, **kwargs): @receiver(post_save, sender=Room) def room_changed_handler(sender, instance: Room, **kwargs): - # Changes might affect: Room size + """ + Signal receiver: Room changed + Changes might affect: Room size + """ # Check room capacities violation_type = ConstraintViolation.ViolationType.ROOM_CAPACITY_EXCEEDED new_violations = [] @@ -583,24 +590,23 @@ def room_changed_handler(sender, instance: Room, **kwargs): @receiver(m2m_changed, sender=Room.properties.through) def room_requirements_changed_handler(sender, instance: Room, action: str, **kwargs): """ - Requirements of room changed + Signal Receiver: Requirements of room changed """ # Only signal after change (post_add, post_delete, post_clear) are relevant if not action.startswith("post"): return - # print(f"{instance} changed") - - event = instance.event - + # event = instance.event # TODO React to changes @receiver(post_save, sender=Availability) def availability_changed_handler(sender, instance: Availability, **kwargs): - # Changes might affect: category availability, AK availability, Room availability - # print(f"{instance} changed") + """ + Signal receiver: Availalability changed + Changes might affect: category availability, AK availability, Room availability + """ event = instance.event # An AK's availability changed: Might affect AK slots scheduled outside the permitted time @@ -627,8 +633,6 @@ def availability_changed_handler(sender, instance: Availability, **kwargs): c.ak_slots_tmp.add(slot) new_violations.append(c) - # print(f"{instance.ak} has the following slots outside availabilities: {new_violations}") - # ... and compare to/update list of existing violations of this type # belonging to the AK that was recently changed (important!) existing_violations_to_check = list(instance.ak.constraintviolation_set.filter(type=violation_type)) @@ -638,7 +642,12 @@ def availability_changed_handler(sender, instance: Availability, **kwargs): @receiver(post_save, sender=Event) def event_changed_handler(sender, instance: Event, **kwargs): - # == Check for reso ak after reso deadline (which might have changed) == + """ + Signal receiver: Event changed + + Changes might affect: Reso deadline + """ + # Check for reso ak after reso deadline (which might have changed) if instance.reso_deadline: for slot in instance.akslot_set.filter(start__isnull=False, ak__reso=True): update_cv_reso_deadline_for_slot(slot) diff --git a/AKScheduling/tests.py b/AKScheduling/tests.py index 8b7bcf91eeb40abe1185b814b0018c2f36715c51..0996eedd905259f0f463589a89f68bde055bc01a 100644 --- a/AKScheduling/tests.py +++ b/AKScheduling/tests.py @@ -1,11 +1,20 @@ +import json +from datetime import timedelta + from django.test import TestCase -from AKModel.tests import BasicViewTests +from django.utils import timezone +from AKModel.tests import BasicViewTests +from AKModel.models import AKSlot, Event, Room class ModelViewTests(BasicViewTests, TestCase): + """ + Tests for AKScheduling + """ fixtures = ['model.json'] VIEWS_STAFF_ONLY = [ + # Views ('admin:schedule', {'event_slug': 'kif42'}), ('admin:slots_unscheduled', {'event_slug': 'kif42'}), ('admin:constraint-violations', {'slug': 'kif42'}), @@ -14,4 +23,66 @@ class ModelViewTests(BasicViewTests, TestCase): ('admin:autocreate-availabilities', {'event_slug': 'kif42'}), ('admin:tracks_manage', {'event_slug': 'kif42'}), ('admin:enter-interest', {'event_slug': 'kif42', 'pk': 1}), + # API (Read) + ('model:scheduling-resources-list', {'event_slug': 'kif42'}, 403), + ('model:scheduling-constraint-violations-list', {'event_slug': 'kif42'}, 403), + ('model:scheduling-events', {'event_slug': 'kif42'}), + ('model:scheduling-room-availabilities', {'event_slug': 'kif42'}), + ('model:scheduling-default-slots', {'event_slug': 'kif42'}), ] + + def test_scheduling_of_slot_update(self): + """ + Test rescheduling a slot to a different time or room + """ + self.client.force_login(self.admin_user) + + event = Event.get_by_slug('kif42') + + # Get the first already scheduled slot belonging to this event + slot = event.akslot_set.filter(start__isnull=False).first() + pk = slot.pk + room_id = slot.room_id + events_api_url = f"/kif42/api/scheduling-event/{pk}/" + + # Create updated time + offset = timedelta(hours=1) + new_start_time = slot.start + offset + new_end_time = slot.end + offset + new_start_time_string = timezone.localtime(new_start_time, event.timezone).strftime("%Y-%m-%d %H:%M:%S") + new_end_time_string = timezone.localtime(new_end_time, event.timezone).strftime("%Y-%m-%d %H:%M:%S") + + # Try API call + response = self.client.put( + events_api_url, + json.dumps({ + 'start': new_start_time_string, + 'end': new_end_time_string, + 'roomId': room_id, + }), + content_type = 'application/json' + ) + self.assertEqual(response.status_code, 200, "PUT to API endpoint did not work") + + # Make sure API call did update the slot as expected + slot = AKSlot.objects.get(pk=pk) + self.assertEqual(new_start_time, slot.start, "Update did not work") + + # Test updating room + new_room = Room.objects.exclude(pk=room_id).first() + + # Try second API call + response = self.client.put( + events_api_url, + json.dumps({ + 'start': new_start_time_string, + 'end': new_end_time_string, + 'roomId': new_room.pk, + }), + content_type = 'application/json' + ) + self.assertEqual(response.status_code, 200, "Second PUT to API endpoint did not work") + + # Make sure API call did update the slot as expected + slot = AKSlot.objects.get(pk=pk) + self.assertEqual(new_room.pk, slot.room.pk, "Update did not work") diff --git a/AKScheduling/views.py b/AKScheduling/views.py index 7174287f22a37ad499fc602ec3e83c5ec687479f..f0be637f0ec4129507d08129e4b2801eeb671f90 100644 --- a/AKScheduling/views.py +++ b/AKScheduling/views.py @@ -7,11 +7,13 @@ from django.views.generic import ListView, DetailView, UpdateView from AKModel.models import AKSlot, AKTrack, Event, AK, AKCategory from AKModel.metaviews.admin import EventSlugMixin, FilterByEventSlugMixin, AdminViewMixin, IntermediateAdminView -from AKScheduling.forms import AKInterestForm -from AKSubmission.forms import AKAddSlotForm +from AKScheduling.forms import AKInterestForm, AKAddSlotForm class UnscheduledSlotsAdminView(AdminViewMixin, FilterByEventSlugMixin, ListView): + """ + Admin view: Get a list of all unscheduled slots + """ template_name = "admin/AKScheduling/unscheduled.html" model = AKSlot context_object_name = "akslots" @@ -26,6 +28,12 @@ class UnscheduledSlotsAdminView(AdminViewMixin, FilterByEventSlugMixin, ListView class SchedulingAdminView(AdminViewMixin, FilterByEventSlugMixin, ListView): + """ + Admin view: Scheduler + + View and adapt the schedule of an event. This view heavily uses JavaScript to display a calendar view plus + a list of unscheduled slots and to allow dragging slots in and into the calendar + """ template_name = "admin/AKScheduling/scheduling.html" model = AKSlot context_object_name = "slots_unscheduled" @@ -47,6 +55,12 @@ class SchedulingAdminView(AdminViewMixin, FilterByEventSlugMixin, ListView): class TrackAdminView(AdminViewMixin, FilterByEventSlugMixin, ListView): + """ + Admin view: Distribute AKs to tracks + + Again using JavaScript, the user can here see a list of all AKs split-up by tracks and can move them to other or + even new tracks using drag and drop. The state is then automatically synchronized via API calls in the background + """ template_name = "admin/AKScheduling/manage_tracks.html" model = AKTrack context_object_name = "tracks" @@ -58,6 +72,12 @@ class TrackAdminView(AdminViewMixin, FilterByEventSlugMixin, ListView): class ConstraintViolationsAdminView(AdminViewMixin, DetailView): + """ + Admin view: Inspect and adjust all constraint violations of the event + + This view populates a table of constraint violations via background API call (JavaScript), offers the option to + see details or edit each of them and provides an auto-reload feature. + """ template_name = "admin/AKScheduling/constraint_violations.html" model = Event context_object_name = "event" @@ -69,6 +89,10 @@ class ConstraintViolationsAdminView(AdminViewMixin, DetailView): class SpecialAttentionAKsAdminView(AdminViewMixin, DetailView): + """ + Admin view: List all AKs that require special attention via scheduling, e.g., because of free-form comments, + since there are slots even though it is a wish, or no slots even though it is an AK etc. + """ template_name = "admin/AKScheduling/special_attention.html" model = Event context_object_name = "event" @@ -77,12 +101,16 @@ class SpecialAttentionAKsAdminView(AdminViewMixin, DetailView): context = super().get_context_data(**kwargs) context["title"] = f"{_('AKs requiring special attention for')} {context['event']}" - aks = AK.objects.filter(event=context["event"]).annotate(Count('owners', distinct=True)).annotate(Count('akslot', distinct=True)).annotate(Count('availabilities', distinct=True)) + # Load all "special" AKs from the database using annotations to reduce the amount of necessary queries + aks = (AK.objects.filter(event=context["event"]).annotate(Count('owners', distinct=True)) + .annotate(Count('akslot', distinct=True)).annotate(Count('availabilities', distinct=True))) aks_with_comment = [] ak_wishes_with_slots = [] aks_without_availabilities = [] aks_without_slots = [] + # Loop over all AKs of this event and identify all relevant factors that make the AK "special" and add them to + # the respective lists if the AK fullfills an condition for ak in aks: if ak.notes != "": aks_with_comment.append(ak) @@ -105,6 +133,14 @@ class SpecialAttentionAKsAdminView(AdminViewMixin, DetailView): class InterestEnteringAdminView(SuccessMessageMixin, AdminViewMixin, EventSlugMixin, UpdateView): + """ + Admin view: Form view to quickly store information about the interest in an AK + (e.g., during presentation of the AK list) + + The view offers a field to update interest and manually set a comment for the current AK, but also features links + to the AKs before and probably coming up next, as well as links to other AKs sorted by category, for quick + and hazzle-free navigation during the AK presentation + """ template_name = "admin/AKScheduling/interest.html" model = AK context_object_name = "ak" @@ -127,6 +163,8 @@ class InterestEnteringAdminView(SuccessMessageMixin, AdminViewMixin, EventSlugMi last_ak = None next_is_next = False + # Building the right navigation is a bit tricky since wishes have to be treated as an own category here + # Hence, depending on the AK we are currently at (displaying the form for) we need to either: # Find other AK wishes (regardless of the category)... if context['ak'].wish: other_aks = [ak for ak in context['event'].ak_set.prefetch_related('owners').all() if ak.wish] @@ -134,6 +172,7 @@ class InterestEnteringAdminView(SuccessMessageMixin, AdminViewMixin, EventSlugMi else: other_aks = [ak for ak in context['ak'].category.ak_set.prefetch_related('owners').all() if not ak.wish] + # Use that list of other AKs belonging to this category to identify the previous and next AK (if any) for other_ak in other_aks: if next_is_next: context['next_ak'] = other_ak @@ -143,6 +182,7 @@ class InterestEnteringAdminView(SuccessMessageMixin, AdminViewMixin, EventSlugMi next_is_next = True last_ak = other_ak + # Gather information for link lists for all categories (and wishes) for category in context['event'].akcategory_set.prefetch_related('ak_set').all(): aks_for_category = [] for ak in category.ak_set.prefetch_related('owners').all(): @@ -152,6 +192,8 @@ class InterestEnteringAdminView(SuccessMessageMixin, AdminViewMixin, EventSlugMi aks_for_category.append(ak) categories_with_aks.append((category, aks_for_category)) + # Make sure wishes have the right order (since the list was filled category by category before, this requires + # explicitly reordering them by their primary key) ak_wishes.sort(key=lambda x: x.pk) categories_with_aks.append( (AKCategory(name=_("Wishes"), pk=0, description="-"), ak_wishes)) @@ -162,6 +204,16 @@ class InterestEnteringAdminView(SuccessMessageMixin, AdminViewMixin, EventSlugMi class WishSlotCleanupView(EventSlugMixin, IntermediateAdminView): + """ + Admin action view: Allow to delete all unscheduled slots for wishes + + The view will render a preview of all slots that are affected by this. It is not possible to manually choose + which slots should be deleted (either all or none) and the functionality will therefore delete slots that were + created in the time between rendering of the preview and running the action ofter confirmation as well. + + Due to the automated slot cleanup functionality for wishes in the AKSubmission app, this functionality should be + rarely needed/used + """ title = _('Cleanup: Delete unscheduled slots for wishes') def get_success_url(self): @@ -181,6 +233,15 @@ class WishSlotCleanupView(EventSlugMixin, IntermediateAdminView): class AvailabilityAutocreateView(EventSlugMixin, IntermediateAdminView): + """ + Admin action view: Allow to automatically create default availabilities (event start to end) for all AKs without + any manually specified availability information + + The view will render a preview of all AKs that are affected by this. It is not possible to manually choose + which AKs should be affected (either all or none) and the functionality will therefore create availability entries + for AKs that were created in the time between rendering of the preview and running the action ofter confirmation + as well. + """ title = _('Create default availabilities for AKs') def get_success_url(self): @@ -195,6 +256,8 @@ class AvailabilityAutocreateView(EventSlugMixin, IntermediateAdminView): ) def form_valid(self, form): + # Local import to prevent cyclic imports + # pylint: disable=import-outside-toplevel from AKModel.availability.models import Availability success_count = 0 @@ -203,7 +266,7 @@ class AvailabilityAutocreateView(EventSlugMixin, IntermediateAdminView): availability = Availability.with_event_length(event=self.event, ak=ak) availability.save() success_count += 1 - except: + except: # pylint: disable=bare-except messages.add_message( self.request, messages.WARNING, _("Could not create default availabilities for AK: {ak}").format(ak=ak) diff --git a/AKSubmission/admin.py b/AKSubmission/admin.py deleted file mode 100644 index 846f6b4061a68eda58bc9c76c36603d1e7721ee8..0000000000000000000000000000000000000000 --- a/AKSubmission/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/AKSubmission/api.py b/AKSubmission/api.py index db46d40c0455c0553bb311302cc39145c41af1f7..4bcbafa8eb85d80118e4f0ff8b2d8e382c04feb8 100644 --- a/AKSubmission/api.py +++ b/AKSubmission/api.py @@ -25,9 +25,13 @@ def ak_interest_indication_active(event, current_timestamp): def increment_interest_counter(request, event_slug, pk, **kwargs): """ Increment interest counter for AK + + This view either returns a HTTP 200 if the counter was incremented, + an HTTP 403 if indicating interest is currently not allowed, + or an HTTP 404 if there is no matching AK for the given primary key and event slug. """ try: - ak = AK.objects.get(pk=pk) + ak = AK.objects.get(pk=pk, event__slug=event_slug) # Check whether interest indication is currently allowed current_timestamp = datetime.now().astimezone(ak.event.timezone) if ak_interest_indication_active(ak.event, current_timestamp): diff --git a/AKSubmission/apps.py b/AKSubmission/apps.py index 6a3d8e74a76d551e512471836bf7c673d047a970..51527b4e8d2eb0f9b88bc4f42ffdf114d6f4fcaf 100644 --- a/AKSubmission/apps.py +++ b/AKSubmission/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class AksubmissionConfig(AppConfig): + """ + App configuration (default, only specifies name of the app) + """ name = 'AKSubmission' diff --git a/AKSubmission/forms.py b/AKSubmission/forms.py index 3f324bd41e6e70c0707e557a43f026843a7ac3af..1ee786d22963dacf1301d1b2984b7771defc1d58 100644 --- a/AKSubmission/forms.py +++ b/AKSubmission/forms.py @@ -1,3 +1,7 @@ +""" +Submission-specific forms +""" + import itertools import re @@ -7,10 +11,21 @@ from django.utils.translation import gettext_lazy as _ from AKModel.availability.forms import AvailabilitiesFormMixin from AKModel.availability.models import Availability -from AKModel.models import AK, AKOwner, AKCategory, AKRequirement, AKSlot, AKOrgaMessage, Event +from AKModel.models import AK, AKOwner, AKCategory, AKRequirement, AKSlot, AKOrgaMessage class AKForm(AvailabilitiesFormMixin, forms.ModelForm): + """ + Base form to add and edit AKs + + Contains suitable widgets for the different data types, restricts querysets (e.g., of requirements) to entries + belonging to the event this AK belongs to. + Prepares initial slot creation (by accepting multiple input formats and a list of slots to generate), + automatically generate short names and wiki links if necessary + + Will be modified/used by :class:`AKSubmissionForm` (that allows to add slots and excludes links) + and :class:`AKWishForm` + """ required_css_class = 'required' split_string = re.compile('[,;]') @@ -57,7 +72,14 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm): @staticmethod def _clean_duration(duration): - # Handle different duration formats (h:mm and decimal comma instead of point) + """ + Clean/convert input format for the duration(s) of the slot(s) + + Handle different duration formats (h:mm and decimal comma instead of point) + + :param duration: raw input, either with ":", "," or "." + :return: normalized duration (point-separated hour float) + """ if ":" in duration: h, m = duration.split(":") duration = int(h) + int(m) / 60 @@ -66,31 +88,44 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm): try: float(duration) - except ValueError: + except ValueError as exc: raise ValidationError( _('"%(duration)s" is not a valid duration'), code='invalid', params={'duration': duration}, - ) + ) from exc return duration def clean(self): + """ + Normalize/clean inputs + + Generate a (not yet used) short name if field was left blank, generate a wiki link, + create a list of normalized slot durations + + :return: cleaned inputs + """ cleaned_data = super().clean() # Generate short name if not given short_name = self.cleaned_data["short_name"] if len(short_name) == 0: short_name = self.cleaned_data['name'] + # First try to split AK name at positions with semantic value (e.g., where the full name is separated + # by a ':'), if not possible, do a hard cut at the maximum specified length short_name = short_name.partition(':')[0] short_name = short_name.partition(' - ')[0] short_name = short_name.partition(' (')[0] short_name = short_name[:AK._meta.get_field('short_name').max_length] + # Check whether this short name already exists... for i in itertools.count(1): + # ...and either use it... if not AK.objects.filter(short_name=short_name, event=self.cleaned_data["event"]).exists(): break + # ... or postfix a number starting at 1 and growing until an unused short name is found digits = len(str(i)) - short_name = '{}-{}'.format(short_name[:-(digits + 1)], i) + short_name = f'{short_name[:-(digits + 1)]}-{i}' cleaned_data["short_name"] = short_name # Generate wiki link @@ -106,35 +141,57 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm): class AKSubmissionForm(AKForm): + """ + Form for Submitting new AKs + + Is a special variant of :class:`AKForm` that does not allow to manually edit wiki and protocol links and enforces + the generation of at least one slot. + """ class Meta(AKForm.Meta): - exclude = ['link', 'protocol_link'] + # Exclude fields again that were previously included in the parent class + exclude = ['link', 'protocol_link'] #pylint: disable=modelform-uses-exclude def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Add field for durations + # Add field for durations (cleaning will be handled by parent class) self.fields["durations"] = forms.CharField( widget=forms.Textarea, label=_("Duration(s)"), help_text=_( - "Enter at least one planned duration (in hours). If your AK should have multiple slots, use multiple lines"), - initial= - self.initial.get('event').default_slot + "Enter at least one planned duration (in hours). " + "If your AK should have multiple slots, use multiple lines"), + initial=self.initial.get('event').default_slot ) def clean_availabilities(self): + """ + Automatically improve availabilities entered. + If the user did not specify availabilities assume the full event duration is possible + :return: cleaned availabilities + (either user input or one availability for the full length of the event if user input was empty) + """ availabilities = super().clean_availabilities() - # If the user did not specify availabilities assume the full event duration is possible if len(availabilities) == 0: availabilities.append(Availability.with_event_length(event=self.cleaned_data["event"])) return availabilities class AKWishForm(AKForm): + """ + Form for submitting or editing wishes + + Is a special variant of :class:`AKForm` that does not allow to specify owner(s) or + manually edit wiki and protocol links + """ class Meta(AKForm.Meta): - exclude = ['owners', 'link', 'protocol_link'] + # Exclude fields again that were previously included in the parent class + exclude = ['owners', 'link', 'protocol_link'] #pylint: disable=modelform-uses-exclude class AKOwnerForm(forms.ModelForm): + """ + Form to create/edit AK owners + """ required_css_class = 'required' class Meta: @@ -146,6 +203,9 @@ class AKOwnerForm(forms.ModelForm): class AKDurationForm(forms.ModelForm): + """ + Form to add an additional slot to a given AK + """ class Meta: model = AKSlot fields = ['duration', 'ak', 'event'] @@ -156,6 +216,9 @@ class AKDurationForm(forms.ModelForm): class AKOrgaMessageForm(forms.ModelForm): + """ + Form to create a confidential message to the organizers belonging to a given AK + """ class Meta: model = AKOrgaMessage fields = ['ak', 'text', 'event'] @@ -164,14 +227,3 @@ class AKOrgaMessageForm(forms.ModelForm): 'event': forms.HiddenInput, 'text': forms.Textarea, } - - -class AKAddSlotForm(forms.Form): - start = forms.CharField(label=_("Start"), disabled=True) - end = forms.CharField(label=_("End"), disabled=True) - duration = forms.CharField(label=_("Duration"), disabled=True) - room = forms.IntegerField(label=_("Room"), disabled=True) - - def __init__(self, event): - super().__init__() - self.fields['ak'] = forms.ModelChoiceField(event.ak_set.all(), label=_("AK")) diff --git a/AKSubmission/locale/de_DE/LC_MESSAGES/django.po b/AKSubmission/locale/de_DE/LC_MESSAGES/django.po index b8462e566cae32be6292571c7b6e1a78999b2d22..b25db7600b98a97b8498ea72067f9504f5b2b1c8 100644 --- a/AKSubmission/locale/de_DE/LC_MESSAGES/django.po +++ b/AKSubmission/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-15 20:03+0200\n" +"POT-Creation-Date: 2023-08-16 16:30+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" @@ -17,16 +17,16 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: AKSubmission/forms.py:71 +#: AKSubmission/forms.py:93 #, python-format msgid "\"%(duration)s\" is not a valid duration" msgstr "\"%(duration)s\" ist keine gültige Dauer" -#: AKSubmission/forms.py:117 +#: AKSubmission/forms.py:159 msgid "Duration(s)" msgstr "Dauer(n)" -#: AKSubmission/forms.py:119 +#: AKSubmission/forms.py:161 msgid "" "Enter at least one planned duration (in hours). If your AK should have " "multiple slots, use multiple lines" @@ -34,33 +34,6 @@ msgstr "" "Mindestens eine geplante Dauer (in Stunden) angeben. Wenn der AK mehrere " "Slots haben soll, mehrere Zeilen verwenden" -#: AKSubmission/forms.py:170 -#: AKSubmission/templates/AKSubmission/ak_detail.html:309 -msgid "Start" -msgstr "Start" - -#: AKSubmission/forms.py:171 -#: AKSubmission/templates/AKSubmission/ak_detail.html:310 -msgid "End" -msgstr "Ende" - -#: AKSubmission/forms.py:172 -#: AKSubmission/templates/AKSubmission/ak_detail.html:239 -#: AKSubmission/templates/AKSubmission/akslot_delete.html:35 -msgid "Duration" -msgstr "Dauer" - -#: AKSubmission/forms.py:173 -#: AKSubmission/templates/AKSubmission/ak_detail.html:241 -msgid "Room" -msgstr "Raum" - -#: AKSubmission/forms.py:177 -#: AKSubmission/templates/AKSubmission/ak_history.html:11 -#: AKSubmission/templates/AKSubmission/akslot_delete.html:31 -msgid "AK" -msgstr "AK" - #: AKSubmission/templates/AKSubmission/ak_detail.html:22 #: AKSubmission/templates/AKSubmission/ak_edit.html:13 #: AKSubmission/templates/AKSubmission/ak_history.html:16 @@ -215,6 +188,15 @@ msgstr "Notizen" msgid "When?" msgstr "Wann?" +#: AKSubmission/templates/AKSubmission/ak_detail.html:239 +#: AKSubmission/templates/AKSubmission/akslot_delete.html:35 +msgid "Duration" +msgstr "Dauer" + +#: AKSubmission/templates/AKSubmission/ak_detail.html:241 +msgid "Room" +msgstr "Raum" + #: AKSubmission/templates/AKSubmission/ak_detail.html:272 msgid "Delete" msgstr "Löschen" @@ -231,6 +213,14 @@ msgstr "Einen neuen AK-Slot hinzufügen" msgid "Possible Times" msgstr "Mögliche Zeiten" +#: AKSubmission/templates/AKSubmission/ak_detail.html:309 +msgid "Start" +msgstr "Start" + +#: AKSubmission/templates/AKSubmission/ak_detail.html:310 +msgid "End" +msgstr "Ende" + #: AKSubmission/templates/AKSubmission/ak_edit.html:8 #: AKSubmission/templates/AKSubmission/ak_history.html:11 #: AKSubmission/templates/AKSubmission/ak_overview.html:8 @@ -260,6 +250,11 @@ msgstr "" "Person hinzufügen, die noch nicht in der Liste ist. Ungespeicherte " "Änderungen in diesem Formular gehen verloren." +#: AKSubmission/templates/AKSubmission/ak_history.html:11 +#: AKSubmission/templates/AKSubmission/akslot_delete.html:31 +msgid "AK" +msgstr "AK" + #: AKSubmission/templates/AKSubmission/ak_history.html:27 msgid "Back" msgstr "Zurück" @@ -283,7 +278,7 @@ msgstr "Die Ergebnisse dieses AKs vorstellen" msgid "Intends to submit a resolution" msgstr "Beabsichtigt eine Resolution einzureichen" -#: AKSubmission/templates/AKSubmission/ak_list.html:6 AKSubmission/views.py:42 +#: AKSubmission/templates/AKSubmission/ak_list.html:6 AKSubmission/views.py:84 msgid "All AKs" msgstr "Alle AKs" @@ -409,74 +404,74 @@ msgstr "" msgid "Submit" msgstr "Eintragen" -#: AKSubmission/views.py:73 +#: AKSubmission/views.py:127 msgid "Wishes" msgstr "Wünsche" -#: AKSubmission/views.py:73 +#: AKSubmission/views.py:127 msgid "AKs one would like to have" msgstr "" "AKs die sich gewünscht wurden, aber bei denen noch nicht klar ist, wer sie " "macht. Falls du dir das vorstellen kannst, trag dich einfach ein" -#: AKSubmission/views.py:93 +#: AKSubmission/views.py:167 msgid "Currently planned AKs" msgstr "Aktuell geplante AKs" -#: AKSubmission/views.py:186 +#: AKSubmission/views.py:300 msgid "Event inactive. Cannot create or update." msgstr "Event inaktiv. Hinzufügen/Bearbeiten nicht möglich." -#: AKSubmission/views.py:202 +#: AKSubmission/views.py:325 msgid "AK successfully created" msgstr "AK erfolgreich angelegt" -#: AKSubmission/views.py:252 +#: AKSubmission/views.py:398 msgid "AK successfully updated" msgstr "AK erfolgreich aktualisiert" -#: AKSubmission/views.py:290 +#: AKSubmission/views.py:449 #, python-brace-format msgid "Added '{owner}' as new owner of '{ak.name}'" msgstr "'{owner}' als neue Leitung von '{ak.name}' hinzugefügt" -#: AKSubmission/views.py:333 -msgid "Person Info successfully updated" -msgstr "Personen-Info erfolgreich aktualisiert" - -#: AKSubmission/views.py:355 +#: AKSubmission/views.py:553 msgid "No user selected" msgstr "Keine Person ausgewählt" -#: AKSubmission/views.py:382 +#: AKSubmission/views.py:569 +msgid "Person Info successfully updated" +msgstr "Personen-Info erfolgreich aktualisiert" + +#: AKSubmission/views.py:605 msgid "AK Slot successfully added" msgstr "AK-Slot erfolgreich angelegt" -#: AKSubmission/views.py:395 +#: AKSubmission/views.py:624 msgid "You cannot edit a slot that has already been scheduled" msgstr "Bereits geplante AK-Slots können nicht mehr bearbeitet werden" -#: AKSubmission/views.py:405 +#: AKSubmission/views.py:634 msgid "AK Slot successfully updated" msgstr "AK-Slot erfolgreich aktualisiert" -#: AKSubmission/views.py:417 +#: AKSubmission/views.py:652 msgid "You cannot delete a slot that has already been scheduled" msgstr "Bereits geplante AK-Slots können nicht mehr gelöscht werden" -#: AKSubmission/views.py:427 +#: AKSubmission/views.py:662 msgid "AK Slot successfully deleted" msgstr "AK-Slot erfolgreich angelegt" -#: AKSubmission/views.py:434 +#: AKSubmission/views.py:674 msgid "Messages" msgstr "Nachrichten" -#: AKSubmission/views.py:444 +#: AKSubmission/views.py:684 msgid "Delete all messages" msgstr "Alle Nachrichten löschen" -#: AKSubmission/views.py:467 +#: AKSubmission/views.py:711 msgid "Message to organizers successfully saved" msgstr "Nachricht an die Organisator*innen erfolgreich gespeichert" diff --git a/AKSubmission/models.py b/AKSubmission/models.py index ef1bff7a11e17e483fed79316c0cc03ed143a982..a3a5f022ed22b77970dd300543c010dc310fb136 100644 --- a/AKSubmission/models.py +++ b/AKSubmission/models.py @@ -3,14 +3,15 @@ from django.conf import settings from django.core.mail import EmailMessage from django.db.models.signals import post_save from django.dispatch import receiver -from django.urls import reverse_lazy from AKModel.models import AKOrgaMessage, AKSlot @receiver(post_save, sender=AKOrgaMessage) -def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwargs): - # React to newly created Orga message by sending an email +def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwargs): # pylint: disable=unused-argument + """ + React to newly created Orga message by sending an email + """ if created and settings.SEND_MAILS: host = 'https://' + settings.ALLOWED_HOSTS[0] if len(settings.ALLOWED_HOSTS) > 0 else 'http://127.0.0.1:8000' @@ -26,10 +27,12 @@ def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwarg @receiver(post_save, sender=AKSlot) -def slot_created_handler(sender, instance: AKSlot, created, **kwargs): - # React to slots that are created after the plan was already published by sending an email - - if created and settings.SEND_MAILS and apps.is_installed("AKPlan") and not instance.event.plan_hidden and instance.room is None and instance.start is None: +def slot_created_handler(sender, instance: AKSlot, created, **kwargs): # pylint: disable=unused-argument + """ + React to slots that are created after the plan was already published by sending an email + """ + if created and settings.SEND_MAILS and apps.is_installed("AKPlan") \ + and not instance.event.plan_hidden and instance.room is None and instance.start is None: # pylint: disable=too-many-boolean-expressions,line-too-long host = 'https://' + settings.ALLOWED_HOSTS[0] if len(settings.ALLOWED_HOSTS) > 0 else 'http://127.0.0.1:8000' url = f"{host}{instance.ak.detail_url}" diff --git a/AKSubmission/templatetags/__init__.py b/AKSubmission/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/AKSubmission/templatetags/tags_AKSubmission.py b/AKSubmission/templatetags/tags_AKSubmission.py index c1d42989a0c1ab931c2e3ee6746667297451abea..b68fe48a5ec3c929113162c223d1b08d8a049bc7 100644 --- a/AKSubmission/templatetags/tags_AKSubmission.py +++ b/AKSubmission/templatetags/tags_AKSubmission.py @@ -6,6 +6,11 @@ register = template.Library() @register.filter def bool_symbol(bool_val): + """ + Show a nice icon instead of the string true/false + :param bool_val: boolean value to iconify + :return: check or times icon depending on the value + """ if bool_val: return fa6_icon("check", "fas") return fa6_icon("times", "fas") @@ -13,14 +18,34 @@ def bool_symbol(bool_val): @register.inclusion_tag("AKSubmission/tracks_list.html") def track_list(tracks, event_slug): + """ + Generate a clickable list of tracks (one badge per track) based upon the tracks_list template + + :param tracks: tracks to consider + :param event_slug: slug of this event, required for link creation + :return: html fragment containing track links + """ return {"tracks": tracks, "event_slug": event_slug} @register.inclusion_tag("AKSubmission/category_list.html") def category_list(categories, event_slug): + """ + Generate a clickable list of categories (one badge per category) based upon the category_list template + + :param categories: categories to consider + :param event_slug: slug of this event, required for link creation + :return: html fragment containing category links + """ return {"categories": categories, "event_slug": event_slug} @register.inclusion_tag("AKSubmission/category_linked_badge.html") def category_linked_badge(category, event_slug): + """ + Generate a clickable category badge based upon the category_linked_badge template + :param category: category to show/link + :param event_slug: slug of this event, required for link creation + :return: html fragment containing badge + """ return {"category": category, "event_slug": event_slug} diff --git a/AKSubmission/tests.py b/AKSubmission/tests.py index 54ff20a06dda9701362aa5d8017e8313cbf689b0..018289aae6f73b36e2f1f6a11b11c016ee357748 100644 --- a/AKSubmission/tests.py +++ b/AKSubmission/tests.py @@ -9,6 +9,15 @@ from AKModel.tests import BasicViewTests class ModelViewTests(BasicViewTests, TestCase): + """ + Testcases for AKSubmission app. + + This extends :class:`BasicViewTests` for standard view and edit testcases + that are specified in this class as VIEWS and EDIT_TESTCASES. + + Additionally several additional testcases, in particular to test the API + and the dispatching for owner selection and editing are specified. + """ fixtures = ['model.json'] VIEWS = [ @@ -47,24 +56,27 @@ class ModelViewTests(BasicViewTests, TestCase): """ self.client.logout() - view_name_with_prefix, url = self._name_and_url(('akslot_edit', {'event_slug': 'kif42', 'pk': 1})) + _, url = self._name_and_url(('akslot_edit', {'event_slug': 'kif42', 'pk': 1})) response = self.client.get(url) self.assertEqual(response.status_code, 302, msg=f"AK Slot editing ({url}) possible even though slot was already scheduled") self._assert_message(response, "You cannot edit a slot that has already been scheduled") - view_name_with_prefix, url = self._name_and_url(('akslot_delete', {'event_slug': 'kif42', 'pk': 1})) + _, url = self._name_and_url(('akslot_delete', {'event_slug': 'kif42', 'pk': 1})) response = self.client.get(url) self.assertEqual(response.status_code, 302, msg=f"AK Slot deletion ({url}) possible even though slot was already scheduled") self._assert_message(response, "You cannot delete a slot that has already been scheduled") def test_slot_creation_deletion(self): + """ + Test creation and deletion of slots in frontend + """ ak_args = {'event_slug': 'kif42', 'pk': 1} redirect_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs=ak_args) + # Create a valid slot -> Redirect to AK detail page, message to user count_slots = AK.objects.get(pk=1).akslot_set.count() - create_url = reverse_lazy(f"{self.APP_NAME}:akslot_add", kwargs=ak_args) response = self.client.post(create_url, {'ak': 1, 'event': 2, 'duration': 1.5}) self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200, @@ -75,6 +87,8 @@ class ModelViewTests(BasicViewTests, TestCase): # Get primary key of newly created Slot slot_pk = AK.objects.get(pk=1).akslot_set.order_by('pk').last().pk + # Edit the recently created slot: Make sure view is accessible, post change + # -> redirect to detail page, duration updated edit_url = reverse_lazy(f"{self.APP_NAME}:akslot_edit", kwargs={'event_slug': 'kif42', 'pk': slot_pk}) response = self.client.get(edit_url) self.assertEqual(response.status_code, 200, msg=f"Cant open edit view for newly created slot ({edit_url})") @@ -84,6 +98,8 @@ class ModelViewTests(BasicViewTests, TestCase): self.assertEqual(AKSlot.objects.get(pk=slot_pk).duration, 2, msg="Slot was not correctly changed") + # Delete recently created slot: Make sure view is accessible, post deletion + # -> redirect to detail page, slot deleted, message to user deletion_url = reverse_lazy(f"{self.APP_NAME}:akslot_delete", kwargs={'event_slug': 'kif42', 'pk': slot_pk}) response = self.client.get(deletion_url) self.assertEqual(response.status_code, 200, @@ -95,55 +111,77 @@ class ModelViewTests(BasicViewTests, TestCase): self.assertEqual(AK.objects.get(pk=1).akslot_set.count(), count_slots, msg="AK still has to many slots") def test_ak_owner_editing(self): - # Test editing of new user + """ + Test dispatch of user editing requests + """ edit_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit_dispatch", kwargs={'event_slug': 'kif42'}) base_url = reverse_lazy(f"{self.APP_NAME}:submission_overview", kwargs={'event_slug': 'kif42'}) + + # Empty form/no user selected -> start page response = self.client.post(edit_url, {'owner_id': -1}) self.assertRedirects(response, base_url, status_code=302, target_status_code=200, msg_prefix="Did not redirect to start page even though no user was selected") self._assert_message(response, "No user selected") + # Correct selection -> user edit page for that user edit_redirect_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit", kwargs={'event_slug': 'kif42', 'slug': 'a'}) response = self.client.post(edit_url, {'owner_id': 1}) self.assertRedirects(response, edit_redirect_url, status_code=302, target_status_code=200, msg_prefix=f"Dispatch redirect failed (should go to {edit_redirect_url})") def test_ak_owner_selection(self): + """ + Test dispatch of owner selection requests + """ select_url = reverse_lazy(f"{self.APP_NAME}:akowner_select", kwargs={'event_slug': 'kif42'}) - create_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) + + # Empty user selection -> create a new user view response = self.client.post(select_url, {'owner_id': -1}) self.assertRedirects(response, create_url, status_code=302, target_status_code=200, msg_prefix="Did not redirect to user create view even though no user was specified") + # Valid user selected -> redirect to view that allows to add a new AK with this user as owner add_redirect_url = reverse_lazy(f"{self.APP_NAME}:submit_ak", kwargs={'event_slug': 'kif42', 'owner_slug': 'a'}) response = self.client.post(select_url, {'owner_id': 1}) self.assertRedirects(response, add_redirect_url, status_code=302, target_status_code=200, msg_prefix=f"Dispatch redirect to ak submission page failed (should go to {add_redirect_url})") def test_orga_message_submission(self): + """ + Test submission and storing of direct confident messages to organizers + """ form_url = reverse_lazy(f"{self.APP_NAME}:akmessage_add", kwargs={'event_slug': 'kif42', 'pk': 1}) detail_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs={'event_slug': 'kif42', 'pk': 1}) count_messages = AK.objects.get(pk=1).akorgamessage_set.count() + # Test that submission view is accessible response = self.client.get(form_url) self.assertEqual(response.status_code, 200, msg="Could not load message form view") + + # Test submission itself and the following redirect -> AK detail page response = self.client.post(form_url, {'ak': 1, 'event': 2, 'text': 'Test message text'}) self.assertRedirects(response, detail_url, status_code=302, target_status_code=200, msg_prefix=f"Did not trigger redirect to ak detail page ({detail_url})") + + # Make sure message was correctly saved in database and user is notified about that self._assert_message(response, "Message to organizers successfully saved") self.assertEqual(AK.objects.get(pk=1).akorgamessage_set.count(), count_messages + 1, msg="Message was not correctly saved") def test_interest_api(self): + """ + Test interest indicating API (access, functionality) + """ interest_api_url = "/kif42/api/ak/1/indicate-interest/" ak = AK.objects.get(pk=1) event = Event.objects.get(slug='kif42') ak_interest_counter = ak.interest_counter + # Check Access method (only POST) response = self.client.get(interest_api_url) self.assertEqual(response.status_code, 405, "Should not be accessible via GET") @@ -151,6 +189,7 @@ class ModelViewTests(BasicViewTests, TestCase): event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=+10) event.save() + # Test correct indication -> HTTP 200, counter increased response = self.client.post(interest_api_url) self.assertEqual(response.status_code, 200, f"API end point not working ({interest_api_url})") self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1, "Counter was not increased") @@ -158,30 +197,41 @@ class ModelViewTests(BasicViewTests, TestCase): event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=-2) event.save() + # Test indication outside of indication window -> HTTP 403, counter not increased response = self.client.post(interest_api_url) self.assertEqual(response.status_code, 403, "API end point still reachable even though interest indication window ended ({interest_api_url})") self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1, "Counter was increased even though interest indication window ended") + # Test call for non-existing AK -> HTTP 403 invalid_interest_api_url = "/kif42/api/ak/-1/indicate-interest/" response = self.client.post(invalid_interest_api_url) self.assertEqual(response.status_code, 404, f"Invalid URL reachable ({interest_api_url})") def test_adding_of_unknown_user(self): + """ + Test adding of a previously not existing owner to an AK + """ + # Pre-Check: AK detail page existing? detail_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs={'event_slug': 'kif42', 'pk': 1}) response = self.client.get(detail_url) self.assertEqual(response.status_code, 200, msg="Could not load ak detail view") + # Make sure AK detail page contains a link to add a new owner edit_url = reverse_lazy(f"{self.APP_NAME}:ak_edit", kwargs={'event_slug': 'kif42', 'pk': 1}) response = self.client.get(edit_url) self.assertEqual(response.status_code, 200, msg="Could not load ak detail view") self.assertContains(response, "Add person not in the list yet", msg_prefix="Link to add unknown user not contained") + # Check adding of a new owner by posting an according request + # -> Redirect to AK detail page, message to user, owners list updated self.assertEqual(AK.objects.get(pk=1).owners.count(), 1) - add_new_user_to_ak_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) + f"?add_to_existing_ak=1" - response = self.client.post(add_new_user_to_ak_url, {'name': 'New test owner', 'event': Event.get_by_slug('kif42').pk}) + add_new_user_to_ak_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) \ + + "?add_to_existing_ak=1" + response = self.client.post(add_new_user_to_ak_url, + {'name': 'New test owner', 'event': Event.get_by_slug('kif42').pk}) self.assertRedirects(response, detail_url, msg_prefix=f"No correct redirect: {add_new_user_to_ak_url} (POST) -> {detail_url}") self._assert_message(response, "Added 'New test owner' as new owner of 'Test AK Inhalt'") diff --git a/AKSubmission/views.py b/AKSubmission/views.py index 3c7fd8e93add9e1a34307ebd7faf594f91ce9eba..e7940a01c0d97a9fba0118265efe3fc9cdd9f84e 100644 --- a/AKSubmission/views.py +++ b/AKSubmission/views.py @@ -1,5 +1,6 @@ from datetime import timedelta from math import floor +from abc import ABC, abstractmethod from django.apps import apps from django.conf import settings @@ -23,27 +24,74 @@ from AKSubmission.forms import AKWishForm, AKOwnerForm, AKSubmissionForm, AKDura class SubmissionErrorNotConfiguredView(EventSlugMixin, TemplateView): + """ + View to show when submission is not correctly configured yet for this event + and hence the submission component cannot be used already. + """ template_name = "AKSubmission/submission_not_configured.html" class AKOverviewView(FilterByEventSlugMixin, ListView): + """ + View: Show a tabbed list of AKs belonging to this event split by categories + + Wishes show up in between of the other AKs in the category they belong to. + In contrast to :class:`SubmissionOverviewView` that inherits from this view, + on this view there is no form to add new AKs or edit owners. + + Since the inherited version of this view will have a slightly different behaviour, + this view contains multiple methods that can be overriden for this adaption. + """ model = AKCategory context_object_name = "categories" template_name = "AKSubmission/ak_overview.html" wishes_as_category = False - def filter_aks(self, context, category): + def filter_aks(self, context, category): # pylint: disable=unused-argument + """ + Filter which AKs to display based on the given context and category + + In the default case, all AKs of that category are returned (including wishes) + + :param context: context of the view + :param category: category to filter the AK list for + :return: filtered list of AKs for the given category + :rtype: QuerySet[AK] + """ + # Use prefetching and relation selection/joining to reduce the amount of necessary queries return category.ak_set.select_related('event').prefetch_related('owners').all() def get_active_category_name(self, context): + """ + Get the category name to display by default/before further user interaction + + In the default case, simply the first category (the one with the lowest ID for this event) is used + + :param context: context of the view + :return: name of the default category + :rtype: str + """ return context["categories_with_aks"][0][0].name - def get_table_title(self, context): + def get_table_title(self, context): # pylint: disable=unused-argument + """ + Specify the title above the AK list/table in this view + + :param context: context of the view + :return: title to use + :rtype: str + """ return _("All AKs") def get(self, request, *args, **kwargs): + """ + Handle GET request + + Overriden to allow checking for correct configuration and + redirect to error page if necessary (see :class:`SubmissionErrorNotConfiguredView`) + """ self._load_event() - self.object_list = self.get_queryset() + self.object_list = self.get_queryset() # pylint: disable=attribute-defined-outside-init # No categories yet? Redirect to configuration error page if self.object_list.count() == 0: @@ -55,10 +103,16 @@ class AKOverviewView(FilterByEventSlugMixin, ListView): def get_context_data(self, *, object_list=None, **kwargs): context = super().get_context_data(object_list=object_list, **kwargs) + # ========================================================== # Sort AKs into different lists (by their category) + # ========================================================== ak_wishes = [] categories_with_aks = [] + # Loop over categories, load AKs (while filtering them if necessary) and create a list of (category, aks)-tuples + # Depending on the setting of self.wishes_as_category, wishes are either included + # or added to a special "Wish"-Category that is created on-the-fly to provide consistent handling in the + # template (without storing it in the database) for category in context["categories"]: aks_for_category = [] for ak in self.filter_aks(context, category): @@ -76,7 +130,9 @@ class AKOverviewView(FilterByEventSlugMixin, ListView): context["active_category"] = self.get_active_category_name(context) context['table_title'] = self.get_table_title(context) + # ========================================================== # Display interest indication button? + # ========================================================== current_timestamp = datetime.now().astimezone(self.event.timezone) context['interest_indication_active'] = ak_interest_indication_active(self.event, current_timestamp) @@ -84,12 +140,30 @@ class AKOverviewView(FilterByEventSlugMixin, ListView): class SubmissionOverviewView(AKOverviewView): + """ + View: List of AKs and possibility to add AKs or adapt owner information + + Main/start view of the component. + + This view inherits from :class:`AKOverviewView`, but treats wishes as separate category if requested in the settings + and handles the change actions mentioned above. + """ model = AKCategory context_object_name = "categories" template_name = "AKSubmission/submission_overview.html" + + # this mainly steers the different handling of wishes + # since the code for that is already included in the parent class wishes_as_category = settings.WISHES_AS_CATEGORY def get_table_title(self, context): + """ + Specify the title above the AK list/table in this view + + :param context: context of the view + :return: title to use + :rtype: str + """ return _("Currently planned AKs") def get_context_data(self, *, object_list=None, **kwargs): @@ -102,32 +176,71 @@ class SubmissionOverviewView(AKOverviewView): class AKListByCategoryView(AKOverviewView): + """ + View: List of only the AKs belonging to a certain category. + + This view inherits from :class:`AKOverviewView`, but produces only one list instead of a tabbed one. + """ def dispatch(self, request, *args, **kwargs): - self.category = get_object_or_404(AKCategory, pk=kwargs['category_pk']) + # Override dispatching + # Needed to handle the checking whether the category exists + self.category = get_object_or_404(AKCategory, pk=kwargs['category_pk']) # pylint: disable=attribute-defined-outside-init,line-too-long return super().dispatch(request, *args, **kwargs) def get_active_category_name(self, context): + """ + Get the category name to display by default/before further user interaction + + In this case, this will be the name of the category specified via pk + + :param context: context of the view + :return: name of the category + :rtype: str + """ return self.category.name class AKListByTrackView(AKOverviewView): + """ + View: List of only the AKs belonging to a certain track. + + This view inherits from :class:`AKOverviewView` and there will be one list per category + -- but only AKs of a certain given track will be included in them. + """ def dispatch(self, request, *args, **kwargs): - self.track = get_object_or_404(AKTrack, pk=kwargs['track_pk']) + # Override dispatching + # Needed to handle the checking whether the track exists + + self.track = get_object_or_404(AKTrack, pk=kwargs['track_pk']) # pylint: disable=attribute-defined-outside-init return super().dispatch(request, *args, **kwargs) def filter_aks(self, context, category): - return category.ak_set.filter(track=self.track) + """ + Filter which AKs to display based on the given context and category + + In this case, the list is further restricted by the track + + :param context: context of the view + :param category: category to filter the AK list for + :return: filtered list of AKs for the given category + :rtype: QuerySet[AK] + """ + return super().filter_aks(context, category).filter(track=self.track) def get_table_title(self, context): return f"{_('AKs with Track')} = {self.track.name}" class AKDetailView(EventSlugMixin, DetailView): + """ + View: AK Details + """ model = AK context_object_name = "ak" template_name = "AKSubmission/ak_detail.html" def get_queryset(self): + # Get information about the AK and do some query optimization return super().get_queryset().select_related('event').prefetch_related('owners') def get_context_data(self, *, object_list=None, **kwargs): @@ -163,29 +276,34 @@ class AKDetailView(EventSlugMixin, DetailView): class AKHistoryView(EventSlugMixin, DetailView): + """ + View: Show history of a given AK + """ model = AK context_object_name = "ak" template_name = "AKSubmission/ak_history.html" -class AKListView(FilterByEventSlugMixin, ListView): - model = AK - context_object_name = "AKs" - template_name = "AKSubmission/ak_overview.html" - table_title = "" - - def get_context_data(self, *, object_list=None, **kwargs): - context = super().get_context_data(object_list=object_list, **kwargs) - context['categories'] = AKCategory.objects.filter(event=self.event) - context['tracks'] = AKTrack.objects.filter(event=self.event) - return context - - class EventInactiveRedirectMixin: + """ + Mixin that will cause a redirect when actions are performed on an inactive event. + Will add a message explaining why the action was not performed to the user + and then redirect to start page of the submission component + """ def get_error_message(self): + """ + Error message to display after redirect (can be adjusted by this method) + + :return: error message + :rtype: str + """ return _("Event inactive. Cannot create or update.") def get(self, request, *args, **kwargs): + """ + Override GET request handling + Will either perform the redirect including the message creation or continue with the planned dispatching + """ s = super().get(request, *args, **kwargs) if not self.event.active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) @@ -194,6 +312,11 @@ class EventInactiveRedirectMixin: class AKAndAKWishSubmissionView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): + """ + View: Submission form for AKs and Wishes + + Base view, will be used by :class:`AKSubmissionView` and :class:`AKWishSubmissionView` + """ model = AK template_name = 'AKSubmission/submit_new.html' form_class = AKSubmissionForm @@ -221,7 +344,14 @@ class AKAndAKWishSubmissionView(EventSlugMixin, EventInactiveRedirectMixin, Crea class AKSubmissionView(AKAndAKWishSubmissionView): + """ + View: AK submission form + + Extends :class:`AKAndAKWishSubmissionView` + """ def get_initial(self): + # Load initial values for the form + # Used to directly add the first owner and the event this AK will belong to initials = super(AKAndAKWishSubmissionView, self).get_initial() initials['owners'] = [AKOwner.get_by_slug(self.event, self.kwargs['owner_slug'])] initials['event'] = self.event @@ -234,33 +364,54 @@ class AKSubmissionView(AKAndAKWishSubmissionView): class AKWishSubmissionView(AKAndAKWishSubmissionView): + """ + View: Wish submission form + + Extends :class:`AKAndAKWishSubmissionView` + """ template_name = 'AKSubmission/submit_new_wish.html' form_class = AKWishForm def get_initial(self): + # Load initial values for the form + # Used to directly select the event this AK will belong to initials = super(AKAndAKWishSubmissionView, self).get_initial() initials['event'] = self.event return initials class AKEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): + """ + View: Update an AK + + This allows to change most fields of an AK as specified in :class:`AKSubmission.forms.AKForm`, + including the availabilities. + It will also handle the change from AK to wish and vice versa (triggered by adding or removing owners) + and automatically create or delete (unscheduled) slots + """ model = AK template_name = 'AKSubmission/ak_edit.html' form_class = AKForm def get_success_url(self): + # Redirection after successfully saving to detail page of AK where also a success message is displayed messages.add_message(self.request, messages.SUCCESS, _("AK successfully updated")) return self.object.detail_url def form_valid(self, form): + # Handle valid form submission + + # Only save when event is active, otherwise redirect if not form.cleaned_data["event"].active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) return redirect(reverse_lazy('submit:submission_overview', kwargs={'event_slug': form.cleaned_data["event"].slug})) + # Remember owner count before saving to know whether the AK changed its state between AK and wish previous_owner_count = self.object.owners.count() - super_form_valid = super().form_valid(form) + # Perform saving and redirect handling by calling default/parent implementation of form_valid + redirect_response = super().form_valid(form) # Did this AK change from wish to AK or vice versa? new_owner_count = self.object.owners.count() @@ -273,15 +424,23 @@ class AKEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): # Delete all unscheduled slots self.object.akslot_set.filter(start__isnull=True).delete() - return super_form_valid + # Redirect to success url + return redirect_response class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): + """ + View: Create a new owner + """ model = AKOwner template_name = 'AKSubmission/akowner_create_update.html' form_class = AKOwnerForm def get_success_url(self): + # The redirect url depends on the source this view was called from: + + # Called from an existing AK? Add the new owner as an owner of that AK, notify the user and redirect to detail + # page of that AK if "add_to_existing_ak" in self.request.GET: ak_pk = self.request.GET['add_to_existing_ak'] ak = get_object_or_404(AK, pk=ak_pk) @@ -289,15 +448,20 @@ class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): messages.add_message(self.request, messages.SUCCESS, _("Added '{owner}' as new owner of '{ak.name}'").format(owner=self.object, ak=ak)) return ak.detail_url + + # Called from the submission overview? Offer the user to create a new AK with the recently created owner + # prefilled as owner of that AK in the creation form return reverse_lazy('submit:submit_ak', kwargs={'event_slug': self.kwargs['event_slug'], 'owner_slug': self.object.slug}) def get_initial(self): - initials = super(AKOwnerCreateView, self).get_initial() + # Set the event in the (hidden) event field in the form based on the URL this view was called with + initials = super().get_initial() initials['event'] = self.event return initials def form_valid(self, form): + # Prevent changes if event is not active if not form.cleaned_data["event"].active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) return redirect(reverse_lazy('submit:submission_overview', @@ -305,29 +469,98 @@ class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): return super().form_valid(form) -class AKOwnerSelectDispatchView(EventSlugMixin, View): +class AKOwnerDispatchView(ABC, EventSlugMixin, View): """ - This view only serves as redirect to prepopulate the owners field in submission create view + Base view: Dispatch to correct view based upon + + Will be used by :class:`AKOwnerSelectDispatchView` and :class:`AKOwnerEditDispatchView` to handle button clicks for + "New AK" and "Edit Person Info" in submission overview based upon the selection in the owner dropdown field """ + @abstractmethod + def get_new_owner_redirect(self, event_slug): + """ + Get redirect when user selected "I do not own AKs yet" + + :param event_slug: slug of the event, needed for constructing redirect + :return: redirect to perform + :rtype: HttpResponseRedirect + """ + + @abstractmethod + def get_valid_owner_redirect(self, event_slug, owner): + """ + Get redirect when user selected "I do not own AKs yet" + + :param event_slug: slug of the event, needed for constructing redirect + :param owner: owner to perform the dispatching for + :return: redirect to perform + :rtype: HttpResponseRedirect + """ + + def post(self, request, *args, **kwargs): + # This view is solely meant to handle POST requests + # Perform dispatching based on the submitted owner_id + + # No owner_id? Redirect to submission overview view if "owner_id" not in request.POST: return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) owner_id = request.POST["owner_id"] + # Special owner_id "-1" (value of "I do not own AKs yet)? Redirect to owner creation view if owner_id == "-1": - return HttpResponseRedirect( - reverse_lazy('submit:akowner_create', kwargs={'event_slug': kwargs['event_slug']})) + return self.get_new_owner_redirect(kwargs['event_slug']) + # Normal owner_id given? Check vor validity and redirect to AK submission page with that owner prefilled + # or display a 404 error page if no owner for the given id can be found. The latter should only happen when the + # user manipulated the value before sending or when the owner was deleted in backend and the user did not + # reload the dropdown between deletion and sending the dispatch request owner = get_object_or_404(AKOwner, pk=request.POST["owner_id"]) - return HttpResponseRedirect( - reverse_lazy('submit:submit_ak', kwargs={'event_slug': kwargs['event_slug'], 'owner_slug': owner.slug})) + return self.get_valid_owner_redirect(kwargs['event_slug'], owner) def get(self, request, *args, **kwargs): + # This view should never be called with GET, perform a redirect to overview in that case return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) -class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView): +class AKOwnerSelectDispatchView(AKOwnerDispatchView): + """ + View: Handle submission from the owner selection dropdown in submission overview for AK creation + ("New AK" button) + + This view will perform redirects depending on the selection in the owner dropdown field. + Based upon the abstract base view :class:`AKOwnerDispatchView`. + """ + + def get_new_owner_redirect(self, event_slug): + return redirect('submit:akowner_create', event_slug=event_slug) + + def get_valid_owner_redirect(self, event_slug, owner): + return redirect('submit:submit_ak', event_slug=event_slug, owner_slug=owner.slug) + + +class AKOwnerEditDispatchView(AKOwnerDispatchView): + """ + View: Handle submission from the owner selection dropdown in submission overview for owner editing + ("Edit Person Info" button) + + This view will perform redirects depending on the selection in the owner dropdown field. + Based upon the abstract base view :class:`AKOwnerDispatchView`. + """ + + def get_new_owner_redirect(self, event_slug): + messages.add_message(self.request, messages.WARNING, _("No user selected")) + return redirect('submit:submission_overview', event_slug) + + def get_valid_owner_redirect(self, event_slug, owner): + return redirect('submit:akowner_edit', event_slug=event_slug, slug=owner.slug) + + +class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, EventInactiveRedirectMixin, UpdateView): + """ + View: Edit an owner + """ model = AKOwner template_name = "AKSubmission/akowner_create_update.html" form_class = AKOwnerForm @@ -337,6 +570,7 @@ class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView): return reverse_lazy('submit:submission_overview', kwargs={'event_slug': self.kwargs['event_slug']}) def form_valid(self, form): + # Prevent updating if event is not active if not form.cleaned_data["event"].active: messages.add_message(self.request, messages.ERROR, self.get_error_message()) return redirect(reverse_lazy('submit:submission_overview', @@ -344,36 +578,19 @@ class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView): return super().form_valid(form) -class AKOwnerEditDispatchView(EventSlugMixin, View): - """ - This view only serves as redirect choose the correct edit view +class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): """ + View: Add an additional slot to an AK + The user has to select the duration of the slot in this view - def post(self, request, *args, **kwargs): - if "owner_id" not in request.POST: - return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) - owner_id = request.POST["owner_id"] - - if owner_id == "-1": - messages.add_message(self.request, messages.WARNING, _("No user selected")) - return HttpResponseRedirect( - reverse_lazy('submit:submission_overview', kwargs={'event_slug': kwargs['event_slug']})) - - owner = get_object_or_404(AKOwner, pk=request.POST["owner_id"]) - return HttpResponseRedirect( - reverse_lazy('submit:akowner_edit', kwargs={'event_slug': kwargs['event_slug'], 'slug': owner.slug})) - - def get(self, request, *args, **kwargs): - return redirect('submit:submission_overview', event_slug=kwargs['event_slug']) - - -class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): + The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`) + """ model = AKSlot form_class = AKDurationForm template_name = "AKSubmission/akslot_add_update.html" def get_initial(self): - initials = super(AKSlotAddView, self).get_initial() + initials = super().get_initial() initials['event'] = self.event initials['ak'] = get_object_or_404(AK, pk=self.kwargs['pk']) initials['duration'] = self.event.default_slot @@ -390,6 +607,12 @@ class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView): class AKSlotEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): + """ + View: Update the duration of an AK slot + + The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`) + and only slots that are not scheduled yet may be changed + """ model = AKSlot form_class = AKDurationForm template_name = "AKSubmission/akslot_add_update.html" @@ -413,6 +636,12 @@ class AKSlotEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView): class AKSlotDeleteView(EventSlugMixin, EventInactiveRedirectMixin, DeleteView): + """ + View: Delete an AK slot + + The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`) + and only slots that are not scheduled yet may be deleted + """ model = AKSlot template_name = "AKSubmission/akslot_delete.html" @@ -436,6 +665,11 @@ class AKSlotDeleteView(EventSlugMixin, EventInactiveRedirectMixin, DeleteView): @status_manager.register(name="event_ak_messages") class EventAKMessagesWidget(TemplateStatusWidget): + """ + Status page widget: AK Messages + + A widget to display information about AK-related messages sent to organizers for the given event + """ required_context_type = "event" title = _("Messages") template_name = "admin/AKModel/render_ak_messages.html" @@ -454,12 +688,16 @@ class EventAKMessagesWidget(TemplateStatusWidget): class AKAddOrgaMessageView(EventSlugMixin, CreateView): + """ + View: Form to create a (confidential) message to the organizers as defined in + :class:`AKSubmission.forms.AKOrgaMessageForm` + """ model = AKOrgaMessage form_class = AKOrgaMessageForm template_name = "AKSubmission/akmessage_add.html" def get_initial(self): - initials = super(AKAddOrgaMessageView, self).get_initial() + initials = super().get_initial() initials['ak'] = get_object_or_404(AK, pk=self.kwargs['pk']) initials['event'] = initials['ak'].event return initials diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 900a9753f97b6564abe8d433c652d21ac5437d35..44a6354fdca65e4203db1b0301f5536c6d3b296e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,7 @@ Provide more context by answering these questions: Include details about your configuration and environment: -* **Which version (commit)) are you using?** +* **Which version (commit) are you using?** * **What's the OS you're using**? ### Suggesting Enhancements diff --git a/Utils/setup.sh b/Utils/setup.sh index 7993082cbff409f7bbae515b6e8d3b18edef09fd..1c951824905e99e4650f40a562b633b92d7b8b02 100755 --- a/Utils/setup.sh +++ b/Utils/setup.sh @@ -15,6 +15,14 @@ source venv/bin/activate pip install --upgrade setuptools pip wheel pip install -r requirements.txt +# set environment variable when we want to update in production +if [ "$1" = "--prod" ]; then + export DJANGO_SETTINGS_MODULE=AKPlanning.settings_production +fi +if [ "$1" = "--ci" ]; then + export DJANGO_SETTINGS_MODULE=AKPlanning.settings_ci +fi + # Setup database python manage.py migrate @@ -26,4 +34,12 @@ python manage.py compilemessages -l de_DE # Credentials are entered interactively on CLI python manage.py createsuperuser +# Generate documentation (but not for CI use) +if [ -n "$1" = "--ci" ]; then + cd docs + make html + cd .. +fi + + deactivate diff --git a/Utils/update.sh b/Utils/update.sh index 780baef2ff52ef2a8d96ffb291f900900acdb048..a711b73b912fcd07e8543b6b49d4eb8950ff0d79 100755 --- a/Utils/update.sh +++ b/Utils/update.sh @@ -27,4 +27,10 @@ pip install --upgrade -r requirements.txt ./manage.py collectstatic --noinput ./manage.py compilemessages -l de_DE + +# Update documentation +cd docs +make html +cd .. + touch AKPlanning/wsgi.py diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6c87b539c1a92836fcc92060af8b8fe4ce6192ed --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +_build/ +code/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..d4bb2cbb9eddb1bb1b4f366623044af8e4830919 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/code.rst b/docs/code.rst new file mode 100644 index 0000000000000000000000000000000000000000..690352ce8aa1a395632029f1369e373be4a7b8f9 --- /dev/null +++ b/docs/code.rst @@ -0,0 +1,10 @@ +Code +===== + +.. toctree:: + code/AKDashboard + code/AKModel + code/AKOnline + code/AKPlan + code/AKScheduling + code/AKSubmission diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..f47181771f7cefca631b81f4bd0dd0542a5c92f0 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,83 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +import os +import sys +from recommonmark.parser import CommonMarkParser +import django + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'AK Planning' +copyright = '2023, N. Geisler, B. Hättasch & more' +author = 'N. Geisler, B. Hättasch & more' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinxcontrib.apidoc', # runs sphinx-apidoc automatically as part of sphinx-build + 'sphinx.ext.autodoc', # the autodoc extensions uses files generated by apidoc + "sphinx.ext.autosummary", + "sphinxcontrib_django", + 'sphinx.ext.viewcode', # enable viewing autodoc'd code +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# -- Django specific settings ------------------------------------------------ + +# Add source directory to sys.path +sys.path.insert(0, os.path.abspath("..")) + +# Configure the path to the Django settings module +django_settings = "AKPlanning.settings" +os.environ['DJANGO_SETTINGS_MODULE'] = django_settings + +django.setup() + +# Include the database table names of Django models +django_show_db_tables = True # Boolean, default: False +# Add abstract database tables names (only takes effect if django_show_db_tables is True) +django_show_db_tables_abstract = True # Boolean, default: False + +# Auto-generate API documentation. +os.environ['SPHINX_APIDOC_OPTIONS'] = "members,show-inheritance" + +# -- Input ---- + +source_parsers = { + '.md': CommonMarkParser, +} + +source_suffix = ['.rst', '.md'] + +# -- Extension Conf ---- + +autodoc_member_order = 'bysource' +autodoc_inherit_docstrings = False + +apidoc_module_dir = '../' +apidoc_output_dir = 'code' +apidoc_excluded_paths = ['*/migrations', + 'AKPlanning/', + 'manage.py', + 'docs', + 'locale', + 'Utils', + '*/urls.py', + ] +apidoc_separate_modules = True +apidoc_toc_file = False +apidoc_module_first = True +apidoc_extra_args = ['-f'] +apidoc_project = project + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_static_path = ['_static'] +html_theme = 'sphinx_rtd_theme' diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..50fb2772421632f7195696de5f37f7810b75a11f --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,22 @@ +.. AK Planning documentation master file, created by + sphinx-quickstart on Wed Jun 21 09:54:11 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Start +======================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + usage/usage + code + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000000000000000000000000000000000000..954237b9b9f2b248bb1397a15c055c0af1cad03e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/usage/usage.rst b/docs/usage/usage.rst new file mode 100644 index 0000000000000000000000000000000000000000..e29d849e91999e2c3dea1a9ba8f8aa080b5f7ba5 --- /dev/null +++ b/docs/usage/usage.rst @@ -0,0 +1,5 @@ +Usage +===== + +.. toctree:: + diff --git a/locale/de_DE/LC_MESSAGES/django.po b/locale/de_DE/LC_MESSAGES/django.po index 37a585fa79adb6a71571aa740a3b0dd52d8056cd..c1bbd2db4e5c22f703d2acc6a7388eb37116792b 100644 --- a/locale/de_DE/LC_MESSAGES/django.po +++ b/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-15 20:05+0200\n" +"POT-Creation-Date: 2023-08-16 16:30+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" @@ -27,11 +27,15 @@ msgstr "Wirklich jetzt die Sprache ändern? Das wird das Formular zurücksetzen! msgid "Go to backend" msgstr "Zum Backend" -#: templates/base.html:114 +#: templates/base.html:109 +msgid "Docs" +msgstr "Doku" + +#: templates/base.html:115 msgid "Impress" msgstr "Impressum" -#: templates/base.html:117 +#: templates/base.html:118 msgid "This software is open source" msgstr "Diese Software ist Open Source" diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000000000000000000000000000000000000..cb836685ac68b0b0aac04fd8574c9ac5d9dfb300 --- /dev/null +++ b/pylintrc @@ -0,0 +1,76 @@ +[MAIN] + +ignore=urls.py, migrations, AKPlanning + +load-plugins= + pylint_django, + pylint_django.checkers.migrations + +django-settings-module=AKPlanning.settings + +disable= + C0114, # missing-module-docstring + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=120 + +indent-string=' ' + + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=6 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + + +[BASIC] + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+)|((tags_)*AK[A-Z][a-z0-9_]+))$ + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,a,e,ak,tz,_,pk + +# Allow single-letter variables and enforce lowercase variables with underscores otherwise +variable-rgx=[a-z_][a-z0-9_]{0,30}$ + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). +ignored-classes=SQLObject,WSGIRequest + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E0201 when accessed. +generated-members=objects,DoesNotExist,id,pk,_meta,base_fields,context + +# List of method names used to declare (i.e. assign) instance attributes +defining-attr-methods=__init__,__new__,setUp + + +[DESIGN] + +max-parents=15 + +max-args=8 diff --git a/requirements.txt b/requirements.txt index 8a886c58b920b5d8746a0e9228981006fcc456ac..c537d2312e8d1fd5e83ca6c978f2eb00a48568d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,11 @@ django-libsass==0.9 django-betterforms==2.0.0 mysqlclient==2.2.0 # for production deployment tzdata==2023.3 + +# Documentation +sphinxcontrib-django==2.3 +sphinxcontrib-apidoc==0.3.0 +recommonmark==0.7.1 +django-docs==0.3.3 +sphinx-rtd-theme==1.2.2 +sphinx==6.2.1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000000000000000000000000000000000000..e9591b4f5895df25170c55b21588eaf290f909e1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[pycodestyle] +max-line-length = 120 +exclude = migrations,static +max-complexity = 11 diff --git a/templates/base.html b/templates/base.html index 9d64a9618f90b8f98ce56022577fc2d72c5a1e6f..5c1fd95736cfba150e750d8d2255fe0e483b3f5e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -106,6 +106,7 @@ {% block footer_custom %} {% endblock %} <a href="{% url "admin:index" %}">{% trans "Go to backend" %}</a> · + <a href="{% url "docs_root" %}">{% trans "Docs" %}</a> · {% footer_info as FI %} {% if FI.impress_text %} {{ FI.impress_text }} ·