Skip to content
Snippets Groups Projects
Commit 84584d74 authored by Nadja Geisler's avatar Nadja Geisler :sunny:
Browse files

Merge branch 'feature-better-admin' into 'main'

Add new functionality to backend

Closes #153, #154, and #152

See merge request !133
parents bfc5198b f444f3a6
Branches
No related tags found
No related merge requests found
from django import forms
from django.apps import apps
from django.contrib import admin, messages
from django.contrib.admin import SimpleListFilter, RelatedFieldListFilter, action
from django.contrib.admin import SimpleListFilter, RelatedFieldListFilter, action, display
from django.db.models import Count, F
from django.db.models.functions import Now
from django.http import HttpResponseRedirect
from django.shortcuts import render, redirect
from django.urls import reverse_lazy
from django.urls import reverse_lazy, path
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
......@@ -18,6 +18,8 @@ from AKModel.availability.models import Availability
from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKTag, AKRequirement, AK, AKSlot, Room, AKOrgaMessage, \
ConstraintViolation
from AKModel.urls import get_admin_urls_event_wizard, get_admin_urls_event
from AKModel.views import CVMarkResolvedView, CVSetLevelViolationView, CVSetLevelWarningView, AKResetInterestView, \
AKResetInterestCounterView, PlanPublishView, PlanUnpublishView
class EventRelatedFieldListFilter(RelatedFieldListFilter):
......@@ -36,7 +38,7 @@ class EventAdmin(admin.ModelAdmin):
list_filter = ['active']
list_editable = ['active']
ordering = ['-start']
readonly_fields = ['status_url', 'plan_hidden', 'plan_published_at']
readonly_fields = ['status_url', 'plan_hidden', 'plan_published_at', 'toggle_plan_visibility']
actions = ['publish', 'unpublish']
def add_view(self, request, form_url='', extra_context=None):
......@@ -50,14 +52,27 @@ class EventAdmin(admin.ModelAdmin):
if apps.is_installed("AKScheduling"):
from AKScheduling.urls import get_admin_urls_scheduling
urls.extend(get_admin_urls_scheduling(self.admin_site))
urls.extend([
path('plan/publish/', PlanPublishView.as_view(), name="plan-publish"),
path('plan/unpublish/', PlanUnpublishView.as_view(), name="plan-unpublish"),
])
urls.extend(super().get_urls())
return urls
@display(description=_("Status"))
def status_url(self, obj):
return format_html("<a href='{url}'>{text}</a>",
url=reverse_lazy('admin:event_status', kwargs={'slug': obj.slug}), text=_("Status"))
status_url.short_description = _("Status")
@display(description=_("Toggle plan visibility"))
def toggle_plan_visibility(self, obj):
if obj.plan_hidden:
url = f"{reverse_lazy('admin:plan-publish')}?pks={obj.pk}"
text = _('Publish plan')
else:
url = f"{reverse_lazy('admin:plan-unpublish')}?pks={obj.pk}"
text = _('Unpublish plan')
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
......@@ -66,13 +81,13 @@ class EventAdmin(admin.ModelAdmin):
@action(description=_('Publish plan'))
def publish(self, request, queryset):
queryset.update(plan_published_at=Now(), plan_hidden=False)
self.message_user(request, _('Plan published'), messages.SUCCESS)
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):
queryset.update(plan_published_at=None, plan_hidden=True)
self.message_user(request, _('Plan unpublished'), messages.SUCCESS)
selected = queryset.values_list('pk', flat=True)
return HttpResponseRedirect(f"{reverse_lazy('admin:plan-unpublish')}?pks={','.join(str(pk) for pk in selected)}")
@admin.register(AKOwner)
......@@ -185,24 +200,47 @@ class AKAdmin(SimpleHistoryAdmin):
list_filter = ['event', WishFilter, ('category', EventRelatedFieldListFilter), ('requirements', EventRelatedFieldListFilter)]
list_editable = ['short_name', 'track', 'interest_counter']
ordering = ['pk']
actions = ['wiki_export']
actions = ['wiki_export', 'reset_interest', 'reset_interest_counter']
form = AKAdminForm
@display(boolean=True)
def is_wish(self, obj):
return obj.wish
@action(description=_("Export to wiki syntax"))
def wiki_export(self, request, queryset):
return render(request, 'admin/AKModel/wiki_export.html', context={"AKs": queryset})
wiki_export.short_description = _("Export to wiki syntax")
is_wish.boolean = True
# 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,
hide_empty_categories=True)
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)
def get_urls(self):
urls = [
path('reset-interest/', AKResetInterestView.as_view(), name="ak-reset-interest"),
path('reset-interest-counter/', AKResetInterestCounterView.as_view(), name="ak-reset-interest-counter"),
]
urls.extend(super().get_urls())
return urls
@action(description=_("Reset interest in AKs"))
def reset_interest(self, request, queryset):
selected = queryset.values_list('pk', flat=True)
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):
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)}")
class RoomForm(AvailabilitiesFormMixin, forms.ModelForm):
class Meta:
......@@ -282,14 +320,13 @@ class AKSlotAdmin(admin.ModelAdmin):
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):
if apps.is_installed("AKSubmission") and akslot.ak is not None:
link = f"<a href={reverse('submit:ak_detail', args=[akslot.event.slug, akslot.ak.pk])}>{str(akslot.ak)}</a>"
return mark_safe(link)
return "-"
ak_details_link.short_description = _('AK Details')
@admin.register(Availability)
class AvailabilityAdmin(admin.ModelAdmin):
......@@ -325,7 +362,32 @@ class ConstraintViolationAdminForm(forms.ModelForm):
@admin.register(ConstraintViolation)
class ConstraintViolationAdmin(admin.ModelAdmin):
list_display = ['type', 'level', 'get_details']
list_display = ['type', 'level', 'get_details', 'manually_resolved']
list_filter = ['event']
readonly_fields = ['timestamp']
form = ConstraintViolationAdminForm
actions = ['mark_resolved', 'set_violation', 'set_warning']
def get_urls(self):
urls = [
path('mark-resolved/', CVMarkResolvedView.as_view(), name="cv-mark-resolved"),
path('set-violation/', CVSetLevelViolationView.as_view(), name="cv-set-violation"),
path('set-warning/', CVSetLevelWarningView.as_view(), name="cv-set-warning"),
]
urls.extend(super().get_urls())
return urls
@action(description=_("Mark Constraint Violations as manually resolved"))
def mark_resolved(self, request, queryset):
selected = queryset.values_list('pk', flat=True)
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):
selected = queryset.values_list('pk', flat=True)
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):
selected = queryset.values_list('pk', flat=True)
return HttpResponseRedirect(f"{reverse_lazy('admin:cv-set-warning')}?pks={','.join(str(pk) for pk in selected)}")
......@@ -73,3 +73,31 @@ class NewEventWizardActivateForm(forms.ModelForm):
class AdminIntermediateForm(forms.Form):
pass
class AdminIntermediateActionForm(AdminIntermediateForm):
pks = forms.CharField(widget=forms.HiddenInput)
class SlideExportForm(AdminIntermediateForm):
num_next = forms.IntegerField(
min_value=0,
max_value=6,
initial=3,
label=_("# next AKs"),
help_text=_("How many next AKs should be shown on a slide?"))
presentation_mode = forms.TypedChoiceField(
initial=False,
label=_("Presentation only?"),
widget=forms.RadioSelect,
choices=((True, _('Yes')), (False, _('No'))),
coerce=lambda x: x == "True",
help_text=_("Restrict AKs to those that asked for chance to be presented?"))
wish_notes = forms.TypedChoiceField(
initial=False,
label=_("Space for notes in wishes?"),
widget=forms.RadioSelect,
choices=((True, _('Yes')), (False, _('No'))),
coerce=lambda x: x == "True",
help_text=_("Create symbols indicating space to note down owners and timeslots for wishes, e.g., to be filled "
"out on a touch screen while presenting?"))
This diff is collapsed.
......@@ -73,7 +73,7 @@ class Event(models.Model):
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):
def get_categories_with_aks(self, wishes_seperately=False, filter=lambda ak: True, hide_empty_categories=False):
"""
Get AKCategories as well as a list of AKs belonging to the category for this event
......@@ -97,6 +97,7 @@ class Event(models.Model):
else:
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, ak_wishes
else:
......@@ -105,6 +106,7 @@ class Event(models.Model):
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
......
......@@ -11,6 +11,13 @@
<h2><a href="{% url 'admin:AKModel_event_change' event.pk %}">{{event}}</a></h2>
<h5>{{ event.start }} - {{ event.end }}</h5>
<div class="custom-control custom-switch mt-2 mb-2">
<input type="checkbox" class="custom-control-input" id="planPublishedSwitch"
{% if not event.plan_hidden %}checked{% endif %}
onclick="location.href='{% if event.plan_hidden %}{% url 'admin:plan-publish' %}{% else %}{% url 'admin:plan-unpublish' %}{% endif %}?pks={{event.pk}}';">
<label class="custom-control-label" for="planPublishedSwitch">{% trans "Plan published?" %}</label>
</div>
<div class="row">
<div class="col-md-8">
<h3 class="block-header">{% trans "Categories" %}</h3>
......@@ -89,9 +96,7 @@
<a class="btn btn-success"
href="{% url 'admin:ak_wiki_export' slug=event.slug %}">{% trans "Export AKs for Wiki" %}</a>
<a class="btn btn-success"
href="{% url 'admin:ak_slide_export' event_slug=event.slug %}?num_next=3&wish_notes=False">{% trans "Export AK Slides" %}</a>
<a class="btn btn-success"
href="{% url 'admin:ak_slide_export' event_slug=event.slug %}?num_next=3&presentation_mode">{% trans "Export AK Slides (Presentation AKs only)" %}</a>
href="{% url 'admin:ak_slide_export' event_slug=event.slug %}">{% trans "Export AK Slides" %}</a>
{% endif %}
<h3 class="block-header">{% trans "Requirements" %}</h3>
......
......@@ -6,7 +6,7 @@ from rest_framework.routers import DefaultRouter
from AKModel import views
from AKModel.views import NewEventWizardStartView, NewEventWizardSettingsView, NewEventWizardPrepareImportView, \
NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, EventStatusView, \
AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView, export_slides
AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView, ExportSlidesView
api_router = DefaultRouter()
api_router.register('akowner', views.AKOwnerViewSet, basename='AKOwner')
......@@ -81,6 +81,5 @@ def get_admin_urls_event(admin_site):
name="ak_wiki_export"),
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/', export_slides, name="ak_slide_export"),
path('<slug:event_slug>/ak-slide-export/', ExportSlidesView.as_view(), name="ak_slide_export"),
]
import os
import tempfile
from abc import ABC, abstractmethod
from itertools import zip_longest
from django.contrib import admin, messages
from django.contrib.admin.views.decorators import staff_member_required
from django.http import HttpResponseRedirect
from django.db.models.functions import Now
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView, DetailView, ListView, DeleteView, CreateView, FormView, UpdateView
from django_tex.shortcuts import render_to_pdf
from django_tex.core import render_template_with_context, run_tex_in_directory
from django_tex.response import PDFResponse
from rest_framework import viewsets, permissions, mixins
from AKModel.forms import NewEventWizardStartForm, NewEventWizardSettingsForm, NewEventWizardPrepareImportForm, \
NewEventWizardImportForm, NewEventWizardActivateForm, AdminIntermediateForm
from AKModel.models import Event, AK, AKSlot, Room, AKTrack, AKCategory, AKOwner, AKOrgaMessage, AKRequirement
NewEventWizardImportForm, NewEventWizardActivateForm, AdminIntermediateForm, SlideExportForm, \
AdminIntermediateActionForm
from AKModel.models import Event, AK, AKSlot, Room, AKTrack, AKCategory, AKOwner, AKOrgaMessage, AKRequirement, \
ConstraintViolation
from AKModel.serializers import AKSerializer, AKSlotSerializer, RoomSerializer, AKTrackSerializer, AKCategorySerializer, \
AKOwnerSerializer
......@@ -195,13 +199,12 @@ class AKWikiExportView(AdminViewMixin, DetailView):
return context
class IntermediateAdminView(AdminViewMixin, FormView, ABC):
class IntermediateAdminView(AdminViewMixin, FormView):
template_name = "admin/AKModel/action_intermediate.html"
form_class = AdminIntermediateForm
@abstractmethod
def get_preview(self):
pass
return ""
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
......@@ -217,9 +220,6 @@ class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView):
def get_orga_messages_for_event(self, event):
return AKOrgaMessage.objects.filter(ak__event=event)
def get_preview(self):
return None
def get_success_url(self):
return reverse_lazy('admin:event_status', kwargs={'slug': self.event.slug})
......@@ -326,15 +326,16 @@ class NewEventWizardFinishView(WizardViewMixin, DetailView):
wizard_step = 6
@staff_member_required
def export_slides(request, event_slug):
template_name = 'admin/AKModel/export/slides.tex'
class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
title = _('Export AK Slides')
form_class = SlideExportForm
event = get_object_or_404(Event, slug=event_slug)
def form_valid(self, form):
template_name = 'admin/AKModel/export/slides.tex'
NEXT_AK_LIST_LENGTH = int(request.GET["num_next"]) if "num_next" in request.GET else 3
RESULT_PRESENTATION_MODE = True if "presentation_mode" in request.GET else False
SPACE_FOR_NOTES_IN_WISHES = request.GET["wish_notes"] == "True" if "wish_notes" in request.GET else False
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"]
translations = {
'symbols': _("Symbols"),
......@@ -349,11 +350,11 @@ def export_slides(request, event_slug):
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())]
categories_with_aks, ak_wishes = event.get_categories_with_aks(wishes_seperately=True, filter=lambda
categories_with_aks, ak_wishes = self.event.get_categories_with_aks(wishes_seperately=True, filter=lambda
ak: not RESULT_PRESENTATION_MODE or (ak.present or (ak.present is None and ak.category.present_by_default)))
context = {
'title': event.name,
'title': self.event.name,
'categories_with_aks': [(category, build_ak_list_with_next_aks(ak_list)) for category, ak_list in
categories_with_aks],
'subtitle': _("AKs"),
......@@ -363,4 +364,134 @@ def export_slides(request, event_slug):
"space_for_notes_in_wishes": SPACE_FOR_NOTES_IN_WISHES,
}
return render_to_pdf(request, template_name, context, filename='slides.pdf')
source = render_template_with_context(template_name, context)
# Perform real compilation (run latex twice for correct page numbers)
with tempfile.TemporaryDirectory() as tempdir:
run_tex_in_directory(source, tempdir, template_name=self.template_name)
os.remove(f'{tempdir}/texput.tex')
pdf = run_tex_in_directory(source, tempdir, template_name=self.template_name)
return PDFResponse(pdf, filename='slides.pdf')
class IntermediateAdminActionView(IntermediateAdminView, ABC):
form_class = AdminIntermediateActionForm
entities = None
def get_queryset(self, pks=None):
if pks is None:
pks = self.request.GET['pks']
return self.model.objects.filter(pk__in=pks.split(","))
def get_initial(self):
initial = super().get_initial()
initial['pks'] = self.request.GET['pks']
return initial
def get_preview(self):
self.entities = self.get_queryset()
joined_entities = '\n'.join(str(e) for e in self.entities)
return f"{self.confirmation_message}:\n\n {joined_entities}"
def get_success_url(self):
return reverse(f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist")
@abstractmethod
def action(self, form):
pass
def form_valid(self, form):
self.entities = self.get_queryset(pks=form.cleaned_data['pks'])
self.action(form)
messages.add_message(self.request, messages.SUCCESS, self.success_message)
return super().form_valid(form)
class LoopActionMixin(ABC):
def action(self, form):
self.pre_action()
for entity in self.entities:
self.perform_action(entity)
entity.save()
self.post_action()
@abstractmethod
def perform_action(self, entity):
pass
def pre_action(self):
pass
def post_action(self):
pass
class CVMarkResolvedView(IntermediateAdminActionView):
title = _('Mark Constraint Violations as manually resolved')
model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be marked as manually resolved")
success_message = _("Constraint Violations marked as resolved")
def action(self, form):
self.entities.update(manually_resolved=True)
class CVSetLevelViolationView(IntermediateAdminActionView):
title = _('Set Constraint Violations to level "violation"')
model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be set to level 'violation'")
success_message = _("Constraint Violations set to level 'violation'")
def action(self, form):
self.entities.update(level=ConstraintViolation.ViolationLevel.VIOLATION)
class CVSetLevelWarningView(IntermediateAdminActionView):
title = _('Set Constraint Violations to level "warning"')
model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be set to level 'warning'")
success_message = _("Constraint Violations set to level 'warning'")
def action(self, form):
self.entities.update(level=ConstraintViolation.ViolationLevel.WARNING)
class AKResetInterestView(IntermediateAdminActionView):
title = _("Reset interest in AKs")
model = AK
confirmation_message = _("Interest of the following AKs will be set to not filled (-1):")
success_message = _("Reset of interest in AKs successful.")
def action(self, form):
self.entities.update(interest=-1)
class AKResetInterestCounterView(IntermediateAdminActionView):
title = _("Reset AKs' interest counters")
model = AK
confirmation_message = _("Interest counter of the following AKs will be set to 0:")
success_message = _("AKs' interest counters set back to 0.")
def action(self, form):
self.entities.update(interest_counter=0)
class PlanPublishView(IntermediateAdminActionView):
title = _('Publish plan')
model = Event
confirmation_message = _('Publish the plan(s) of:')
success_message = _('Plan published')
def action(self, form):
self.entities.update(plan_published_at=Now(), plan_hidden=False)
class PlanUnpublishView(IntermediateAdminActionView):
title = _('Unpublish plan')
model = Event
confirmation_message = _('Unpublish the plan(s) of:')
success_message = _('Plan unpublished')
def action(self, form):
self.entities.update(plan_published_at=None, plan_hidden=True)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment