From 33383e6559c25867a92e2d021cffdd83c40c59fc Mon Sep 17 00:00:00 2001 From: Thomas Steen Rasmussen Date: Sat, 22 Feb 2020 14:50:09 +0100 Subject: [PATCH] Event feedback (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Event feedback functionality and related commits: * blackness and isort and flake8 - this branch does not have pre-commit so I forgot :/ * finish backoffice management of eventfeedback * add username to eventfeedback detail panel when viewed in backoffice * Add feedback url to elm schedule. Fix access when user is anonymous. Remove print statement. * one prefetch_related call to rule them all Co-authored-by: Víðir Valberg Guðmundsson --- schedule/src/Views/EventDetail.elm | 4 + .../templates/approve_feedback.html | 32 + src/backoffice/templates/index.html | 4 + src/backoffice/urls.py | 5 + src/backoffice/views.py | 58 +- src/bornhack/settings.py | 1 + src/camps/mixins.py | 6 +- src/camps/views.py | 3 +- src/program/admin.py | 25 + src/program/migrations/0075_eventfeedback.py | 77 ++ .../migrations/0076_auto_20200214_0143.py | 34 + .../migrations/0077_auto_20200214_0246.py | 16 + .../migrations/0078_auto_20200214_2100.py | 27 + src/program/mixins.py | 33 + src/program/models.py | 68 +- src/program/static/js/elm_based_schedule.js | 852 +++++++++--------- src/program/templates/event_list.html | 9 +- .../templates/eventfeedback_delete.html | 18 + .../templates/eventfeedback_detail.html | 7 + src/program/templates/eventfeedback_form.html | 18 + src/program/templates/eventfeedback_list.html | 11 + .../includes/eventfeedback_buttons.html | 6 + .../includes/eventfeedback_detail_panel.html | 57 ++ .../templates/schedule_event_detail.html | 4 +- src/program/templates/speaker_detail.html | 2 +- src/program/templatetags/__init__.py | 0 src/program/templatetags/program.py | 50 + src/program/urls.py | 49 +- src/program/views.py | 137 ++- src/rideshare/views.py | 12 +- src/utils/middleware.py | 30 + src/utils/mixins.py | 7 +- src/utils/templatetags/bornhack.py | 5 + 33 files changed, 1223 insertions(+), 444 deletions(-) create mode 100644 src/backoffice/templates/approve_feedback.html create mode 100644 src/program/migrations/0075_eventfeedback.py create mode 100644 src/program/migrations/0076_auto_20200214_0143.py create mode 100644 src/program/migrations/0077_auto_20200214_0246.py create mode 100644 src/program/migrations/0078_auto_20200214_2100.py create mode 100644 src/program/templates/eventfeedback_delete.html create mode 100644 src/program/templates/eventfeedback_detail.html create mode 100644 src/program/templates/eventfeedback_form.html create mode 100644 src/program/templates/eventfeedback_list.html create mode 100644 src/program/templates/includes/eventfeedback_buttons.html create mode 100644 src/program/templates/includes/eventfeedback_detail_panel.html create mode 100644 src/program/templatetags/__init__.py create mode 100644 src/program/templatetags/program.py create mode 100644 src/utils/middleware.py diff --git a/schedule/src/Views/EventDetail.elm b/schedule/src/Views/EventDetail.elm index 97bb69b0..dc08da2e 100644 --- a/schedule/src/Views/EventDetail.elm +++ b/schedule/src/Views/EventDetail.elm @@ -154,6 +154,9 @@ eventMetaDataSidebar event eventInstances model = [] (List.map (\ei -> li [] <| eventInstanceItem ei model) instances) ] + + feedbackUrl = + [a [href <| "https://bornhack.dk/" ++ model.flags.camp_slug ++ "/program/" ++ event.slug ++ "/feedback/create/" ] [text "Give feedback"]] in div [] ([ h4 [] [ text "Metadata" ] @@ -170,6 +173,7 @@ eventMetaDataSidebar event eventInstances model = ) ] ++ eventInstanceMetaData + ++ feedbackUrl ) diff --git a/src/backoffice/templates/approve_feedback.html b/src/backoffice/templates/approve_feedback.html new file mode 100644 index 00000000..6fa62e0f --- /dev/null +++ b/src/backoffice/templates/approve_feedback.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %} +{% load bornhack %} + +{% block content %} +
+
+
+ BackOffice - Approve Event Feedback +
+
+
+ The Content team can approve or reject pending EventFeedback from this page. The feedback will not be visible to the Event owner before it is approved. The Event owner will not be able to see the username of the feedback authors. The feedback author can see when the feedback has been approved or rejected by returning to the feedback page. +
+ {% if eventfeedback_list %} +
+ {{ formset.management_form }} + {% csrf_token %} + {% for form, feedback in formset|zip:eventfeedback_list %} + {% include "includes/eventfeedback_detail_panel.html" with eventfeedback=feedback event=feedback.event %} + {% endfor %} + + Cancel +
+ {% else %} +
There is no feedback awaiting approval.
+ Cancel + {% endif %} +
+
+
+{% endblock content %} + diff --git a/src/backoffice/templates/index.html b/src/backoffice/templates/index.html index 520e25c5..2fab6e7a 100644 --- a/src/backoffice/templates/index.html +++ b/src/backoffice/templates/index.html @@ -33,6 +33,10 @@

Manage Proposals

Use this view to manage SpeakerProposals and EventProposals

+ +

Approve Feedback

+

Use this view to approve or reject EventFeedback

+
{% endif %} {% if perms.camps.orgateam_permission %} diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py index b24d15e5..2e5d231b 100644 --- a/src/backoffice/urls.py +++ b/src/backoffice/urls.py @@ -1,6 +1,7 @@ from django.urls import include, path from .views import ( + ApproveFeedbackView, ApproveNamesView, BackofficeIndexView, BadgeHandoutView, @@ -80,6 +81,10 @@ urlpatterns = [ ] ), ), + # approve eventfeedback objects + path( + "approve_feedback", ApproveFeedbackView.as_view(), name="approve_eventfeedback", + ), # economy path( "economy/", diff --git a/src/backoffice/views.py b/src/backoffice/views.py index 322ea296..fa470951 100644 --- a/src/backoffice/views.py +++ b/src/backoffice/views.py @@ -2,22 +2,22 @@ import logging import os from itertools import chain +from camps.mixins import CampViewMixin from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.core.files import File from django.db.models import Sum +from django.forms import modelformset_factory from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone from django.views.generic import DetailView, ListView, TemplateView -from django.views.generic.edit import CreateView, DeleteView, UpdateView - -from camps.mixins import CampViewMixin +from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from economy.models import Chain, Credebtor, Expense, Reimbursement, Revenue from profiles.models import Profile -from program.models import EventProposal, SpeakerProposal +from program.models import EventFeedback, EventProposal, SpeakerProposal from shop.models import Order, OrderProductRelation from teams.models import Team from tickets.models import DiscountTicket, ShopTicket, SponsorTicket, TicketType @@ -86,6 +86,55 @@ class ApproveNamesView(CampViewMixin, OrgaTeamPermissionMixin, ListView): ) +class ApproveFeedbackView(CampViewMixin, ContentTeamPermissionMixin, FormView): + """ + This view shows a list of EventFeedback objects which are pending approval. + """ + + model = EventFeedback + template_name = "approve_feedback.html" + + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + self.queryset = EventFeedback.objects.filter( + event__track__camp=self.camp, approved__isnull=True + ) + + self.form_class = modelformset_factory( + EventFeedback, + fields=("approved",), + min_num=self.queryset.count(), + validate_min=True, + max_num=self.queryset.count(), + validate_max=True, + extra=0, + ) + + def get_context_data(self, *args, **kwargs): + """ + Include the queryset used for the modelformset_factory so we have + some idea which object is which in the template + Why the hell do the forms in the formset not include the object? + """ + context = super().get_context_data(*args, **kwargs) + context["eventfeedback_list"] = self.queryset + context["formset"] = self.form_class(queryset=self.queryset) + return context + + def form_valid(self, form): + form.save() + if form.changed_objects: + messages.success( + self.request, f"Updated {len(form.changed_objects)} EventFeedbacks" + ) + return redirect(self.get_success_url()) + + def get_success_url(self, *args, **kwargs): + return reverse( + "backoffice:approve_eventfeedback", kwargs={"camp_slug": self.camp.slug} + ) + + class ManageProposalsView(CampViewMixin, ContentTeamPermissionMixin, ListView): """ This view shows a list of pending SpeakerProposal and EventProposals. @@ -118,7 +167,6 @@ class ProposalManageBaseView(CampViewMixin, ContentTeamPermissionMixin, UpdateVi """ We have two submit buttons in this form, Approve and Reject """ - logger.debug(form.data) if "approve" in form.data: # approve button was pressed form.instance.mark_as_approved(self.request) diff --git a/src/bornhack/settings.py b/src/bornhack/settings.py index fe5df780..da4c5027 100644 --- a/src/bornhack/settings.py +++ b/src/bornhack/settings.py @@ -123,6 +123,7 @@ MIDDLEWARE = [ "django_otp.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "utils.middleware.RedirectExceptionMiddleware", ] CORS_ORIGIN_ALLOW_ALL = True diff --git a/src/camps/mixins.py b/src/camps/mixins.py index dafa8c67..c8edf6bd 100644 --- a/src/camps/mixins.py +++ b/src/camps/mixins.py @@ -3,15 +3,15 @@ from django.shortcuts import get_object_or_404 from camps.models import Camp -class CampViewMixin(object): +class CampViewMixin: """ This mixin makes sure self.camp is available (taken from url kwarg camp_slug) It also filters out objects that belong to other camps when the queryset has a camp_filter """ - def dispatch(self, request, *args, **kwargs): + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) self.camp = get_object_or_404(Camp, slug=self.kwargs["camp_slug"]) - return super().dispatch(request, *args, **kwargs) def get_queryset(self): queryset = super().get_queryset() diff --git a/src/camps/views.py b/src/camps/views.py index f975c90f..0c990ea9 100644 --- a/src/camps/views.py +++ b/src/camps/views.py @@ -6,13 +6,12 @@ from django.utils import timezone from django.views import View from django.views.generic import DetailView, ListView -from .mixins import CampViewMixin from .models import Camp logger = logging.getLogger("bornhack.%s" % __name__) -class CampRedirectView(CampViewMixin, View): +class CampRedirectView(View): def dispatch(self, request, *args, **kwargs): now = timezone.now() diff --git a/src/program/admin.py b/src/program/admin.py index fbedcded..b0549617 100644 --- a/src/program/admin.py +++ b/src/program/admin.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from .models import ( Event, + EventFeedback, EventInstance, EventLocation, EventProposal, @@ -120,3 +121,27 @@ class UrlTypeAdmin(admin.ModelAdmin): @admin.register(Url) class UrlAdmin(admin.ModelAdmin): pass + + +@admin.register(EventFeedback) +class EventFeedbackAdmin(admin.ModelAdmin): + list_display = [ + "camp", + "user", + "event", + "expectations_fulfilled", + "attend_speaker_again", + "rating", + "created", + "updated", + "approved", + "comment", + ] + list_filter = [ + "event__track__camp", + "expectations_fulfilled", + "attend_speaker_again", + "rating", + "approved", + ] + search_fields = ["event__title", "user__username"] diff --git a/src/program/migrations/0075_eventfeedback.py b/src/program/migrations/0075_eventfeedback.py new file mode 100644 index 00000000..0bc8d335 --- /dev/null +++ b/src/program/migrations/0075_eventfeedback.py @@ -0,0 +1,77 @@ +# Generated by Django 3.0.3 on 2020-02-13 16:51 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("program", "0074_auto_20190801_0933"), + ] + + operations = [ + migrations.CreateModel( + name="EventFeedback", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "expectations_fulfilled", + models.BooleanField( + choices=[(True, "Yes"), (False, "No")], + help_text="Did the event live up to your expectations?", + ), + ), + ( + "attend_speaker_again", + models.BooleanField( + choices=[(True, "Yes"), (False, "No")], + help_text="Would you attend another event with the same speaker?", + ), + ), + ( + "rating", + models.IntegerField( + choices=[(0, "0"), (1, "1"), (2, "2"), (3, "3"), (4, "4")], + help_text="Rating/Score (5 is best)", + ), + ), + ( + "feedback", + models.TextField(help_text="Any other comments or feedback?"), + ), + ( + "event", + models.ForeignKey( + help_text="The Event this feedback is about", + on_delete=django.db.models.deletion.PROTECT, + related_name="feedbacks", + to="program.Event", + ), + ), + ( + "user", + models.ForeignKey( + help_text="The User who wrote this feedback", + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + ), + ] diff --git a/src/program/migrations/0076_auto_20200214_0143.py b/src/program/migrations/0076_auto_20200214_0143.py new file mode 100644 index 00000000..55a91160 --- /dev/null +++ b/src/program/migrations/0076_auto_20200214_0143.py @@ -0,0 +1,34 @@ +# Generated by Django 3.0.3 on 2020-02-14 00:43 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("program", "0075_eventfeedback"), + ] + + operations = [ + migrations.AddField( + model_name="eventfeedback", + name="approved", + field=models.BooleanField( + default=False, + help_text="Approve feedback? It will not be visible to Event owner before it is approved.", + ), + ), + migrations.AlterField( + model_name="eventfeedback", + name="rating", + field=models.IntegerField( + choices=[(0, "0"), (1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")], + help_text="Rating/Score (5 is best)", + ), + ), + migrations.AlterUniqueTogether( + name="eventfeedback", unique_together={("user", "event")}, + ), + ] diff --git a/src/program/migrations/0077_auto_20200214_0246.py b/src/program/migrations/0077_auto_20200214_0246.py new file mode 100644 index 00000000..50c23525 --- /dev/null +++ b/src/program/migrations/0077_auto_20200214_0246.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.3 on 2020-02-14 01:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0076_auto_20200214_0143"), + ] + + operations = [ + migrations.RenameField( + model_name="eventfeedback", old_name="feedback", new_name="comment", + ), + ] diff --git a/src/program/migrations/0078_auto_20200214_2100.py b/src/program/migrations/0078_auto_20200214_2100.py new file mode 100644 index 00000000..7d1b2e1a --- /dev/null +++ b/src/program/migrations/0078_auto_20200214_2100.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.3 on 2020-02-14 20:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0077_auto_20200214_0246"), + ] + + operations = [ + migrations.AlterField( + model_name="eventfeedback", + name="approved", + field=models.NullBooleanField( + help_text="Approve feedback? It will not be visible to the Event owner before it is approved." + ), + ), + migrations.AlterField( + model_name="eventfeedback", + name="comment", + field=models.TextField( + blank=True, help_text="Any other comments or feedback?" + ), + ), + ] diff --git a/src/program/mixins.py b/src/program/mixins.py index 8f86978b..f26cfcba 100644 --- a/src/program/mixins.py +++ b/src/program/mixins.py @@ -1,3 +1,4 @@ +from camps.mixins import CampViewMixin from django.contrib import messages from django.http import Http404 from django.shortcuts import get_object_or_404, redirect @@ -109,3 +110,35 @@ class UrlViewMixin(object): return self.eventproposal.get_absolute_url() else: return self.speakerproposal.get_absolute_url() + + +class EventViewMixin(CampViewMixin): + """ + A mixin to get the Event object based on event_uuid in url kwargs + """ + + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + self.event = get_object_or_404( + models.Event, track__camp=self.camp, slug=self.kwargs["event_slug"] + ) + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context["event"] = self.event + return context + + +class EventFeedbackViewMixin(EventViewMixin): + """ + A mixin to get the EventFeedback object based on self.event and self.request.user + """ + + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + self.eventfeedback = get_object_or_404( + models.EventFeedback, event=self.event, user=self.request.user, + ) + + def get_object(self): + return self.eventfeedback diff --git a/src/program/models.py b/src/program/models.py index 6238a790..35c9d7ca 100644 --- a/src/program/models.py +++ b/src/program/models.py @@ -10,10 +10,9 @@ from django.contrib.postgres.fields import DateTimeRangeField from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.files.storage import FileSystemStorage from django.db import models -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils.text import slugify - -from utils.models import CampRelatedModel, CreatedUpdatedModel +from utils.models import CampRelatedModel, CreatedUpdatedModel, UUIDModel logger = logging.getLogger("bornhack.%s" % __name__) @@ -594,7 +593,7 @@ class Event(CampRelatedModel): def get_absolute_url(self): return reverse_lazy( "program:event_detail", - kwargs={"camp_slug": self.camp.slug, "slug": self.slug}, + kwargs={"camp_slug": self.camp.slug, "event_slug": self.slug}, ) def serialize(self): @@ -795,6 +794,67 @@ class Favorite(models.Model): unique_together = ["user", "event_instance"] +############################################################################### + + +class EventFeedback(CampRelatedModel, UUIDModel): + """ + This model contains all feedback for Events + Each user can submit exactly one feedback per Event + """ + + class Meta: + unique_together = [("user", "event")] + + YESNO_CHOICES = [(True, "Yes"), (False, "No")] + + user = models.ForeignKey( + "auth.User", + on_delete=models.PROTECT, + help_text="The User who wrote this feedback", + ) + + event = models.ForeignKey( + "program.event", + related_name="feedbacks", + on_delete=models.PROTECT, + help_text="The Event this feedback is about", + ) + + expectations_fulfilled = models.BooleanField( + choices=YESNO_CHOICES, help_text="Did the event live up to your expectations?", + ) + + attend_speaker_again = models.BooleanField( + choices=YESNO_CHOICES, + help_text="Would you attend another event with the same speaker?", + ) + + RATING_CHOICES = [(n, f"{n}") for n in range(0, 6)] + + rating = models.IntegerField( + choices=RATING_CHOICES, help_text="Rating/Score (5 is best)", + ) + + comment = models.TextField(blank=True, help_text="Any other comments or feedback?") + + approved = models.NullBooleanField( + help_text="Approve feedback? It will not be visible to the Event owner before it is approved." + ) + + @property + def camp(self): + return self.event.camp + + camp_filter = "event__track__camp" + + def get_absolute_url(self): + return reverse( + "program:eventfeedback_detail", + kwargs={"camp_slug": self.camp.slug, "event_slug": self.event.slug}, + ) + + # classes and functions below here was used by picture handling for speakers before it was removed in May 2018 by tyk diff --git a/src/program/static/js/elm_based_schedule.js b/src/program/static/js/elm_based_schedule.js index 1ce403e8..63185e4f 100644 --- a/src/program/static/js/elm_based_schedule.js +++ b/src/program/static/js/elm_based_schedule.js @@ -5851,418 +5851,6 @@ var _NoRedInk$elm_decode_pipeline$Json_Decode_Pipeline$required = F3( decoder); }); -//import Native.Scheduler // - -var _elm_lang$core$Native_Time = function() { - -var now = _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) -{ - callback(_elm_lang$core$Native_Scheduler.succeed(Date.now())); -}); - -function setInterval_(interval, task) -{ - return _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) - { - var id = setInterval(function() { - _elm_lang$core$Native_Scheduler.rawSpawn(task); - }, interval); - - return function() { clearInterval(id); }; - }); -} - -return { - now: now, - setInterval_: F2(setInterval_) -}; - -}(); -var _elm_lang$core$Task$onError = _elm_lang$core$Native_Scheduler.onError; -var _elm_lang$core$Task$andThen = _elm_lang$core$Native_Scheduler.andThen; -var _elm_lang$core$Task$spawnCmd = F2( - function (router, _p0) { - var _p1 = _p0; - return _elm_lang$core$Native_Scheduler.spawn( - A2( - _elm_lang$core$Task$andThen, - _elm_lang$core$Platform$sendToApp(router), - _p1._0)); - }); -var _elm_lang$core$Task$fail = _elm_lang$core$Native_Scheduler.fail; -var _elm_lang$core$Task$mapError = F2( - function (convert, task) { - return A2( - _elm_lang$core$Task$onError, - function (_p2) { - return _elm_lang$core$Task$fail( - convert(_p2)); - }, - task); - }); -var _elm_lang$core$Task$succeed = _elm_lang$core$Native_Scheduler.succeed; -var _elm_lang$core$Task$map = F2( - function (func, taskA) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return _elm_lang$core$Task$succeed( - func(a)); - }, - taskA); - }); -var _elm_lang$core$Task$map2 = F3( - function (func, taskA, taskB) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return _elm_lang$core$Task$succeed( - A2(func, a, b)); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$map3 = F4( - function (func, taskA, taskB, taskC) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return A2( - _elm_lang$core$Task$andThen, - function (c) { - return _elm_lang$core$Task$succeed( - A3(func, a, b, c)); - }, - taskC); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$map4 = F5( - function (func, taskA, taskB, taskC, taskD) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return A2( - _elm_lang$core$Task$andThen, - function (c) { - return A2( - _elm_lang$core$Task$andThen, - function (d) { - return _elm_lang$core$Task$succeed( - A4(func, a, b, c, d)); - }, - taskD); - }, - taskC); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$map5 = F6( - function (func, taskA, taskB, taskC, taskD, taskE) { - return A2( - _elm_lang$core$Task$andThen, - function (a) { - return A2( - _elm_lang$core$Task$andThen, - function (b) { - return A2( - _elm_lang$core$Task$andThen, - function (c) { - return A2( - _elm_lang$core$Task$andThen, - function (d) { - return A2( - _elm_lang$core$Task$andThen, - function (e) { - return _elm_lang$core$Task$succeed( - A5(func, a, b, c, d, e)); - }, - taskE); - }, - taskD); - }, - taskC); - }, - taskB); - }, - taskA); - }); -var _elm_lang$core$Task$sequence = function (tasks) { - var _p3 = tasks; - if (_p3.ctor === '[]') { - return _elm_lang$core$Task$succeed( - {ctor: '[]'}); - } else { - return A3( - _elm_lang$core$Task$map2, - F2( - function (x, y) { - return {ctor: '::', _0: x, _1: y}; - }), - _p3._0, - _elm_lang$core$Task$sequence(_p3._1)); - } -}; -var _elm_lang$core$Task$onEffects = F3( - function (router, commands, state) { - return A2( - _elm_lang$core$Task$map, - function (_p4) { - return {ctor: '_Tuple0'}; - }, - _elm_lang$core$Task$sequence( - A2( - _elm_lang$core$List$map, - _elm_lang$core$Task$spawnCmd(router), - commands))); - }); -var _elm_lang$core$Task$init = _elm_lang$core$Task$succeed( - {ctor: '_Tuple0'}); -var _elm_lang$core$Task$onSelfMsg = F3( - function (_p7, _p6, _p5) { - return _elm_lang$core$Task$succeed( - {ctor: '_Tuple0'}); - }); -var _elm_lang$core$Task$command = _elm_lang$core$Native_Platform.leaf('Task'); -var _elm_lang$core$Task$Perform = function (a) { - return {ctor: 'Perform', _0: a}; -}; -var _elm_lang$core$Task$perform = F2( - function (toMessage, task) { - return _elm_lang$core$Task$command( - _elm_lang$core$Task$Perform( - A2(_elm_lang$core$Task$map, toMessage, task))); - }); -var _elm_lang$core$Task$attempt = F2( - function (resultToMessage, task) { - return _elm_lang$core$Task$command( - _elm_lang$core$Task$Perform( - A2( - _elm_lang$core$Task$onError, - function (_p8) { - return _elm_lang$core$Task$succeed( - resultToMessage( - _elm_lang$core$Result$Err(_p8))); - }, - A2( - _elm_lang$core$Task$andThen, - function (_p9) { - return _elm_lang$core$Task$succeed( - resultToMessage( - _elm_lang$core$Result$Ok(_p9))); - }, - task)))); - }); -var _elm_lang$core$Task$cmdMap = F2( - function (tagger, _p10) { - var _p11 = _p10; - return _elm_lang$core$Task$Perform( - A2(_elm_lang$core$Task$map, tagger, _p11._0)); - }); -_elm_lang$core$Native_Platform.effectManagers['Task'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Task$init, onEffects: _elm_lang$core$Task$onEffects, onSelfMsg: _elm_lang$core$Task$onSelfMsg, tag: 'cmd', cmdMap: _elm_lang$core$Task$cmdMap}; - -var _elm_lang$core$Time$setInterval = _elm_lang$core$Native_Time.setInterval_; -var _elm_lang$core$Time$spawnHelp = F3( - function (router, intervals, processes) { - var _p0 = intervals; - if (_p0.ctor === '[]') { - return _elm_lang$core$Task$succeed(processes); - } else { - var _p1 = _p0._0; - var spawnRest = function (id) { - return A3( - _elm_lang$core$Time$spawnHelp, - router, - _p0._1, - A3(_elm_lang$core$Dict$insert, _p1, id, processes)); - }; - var spawnTimer = _elm_lang$core$Native_Scheduler.spawn( - A2( - _elm_lang$core$Time$setInterval, - _p1, - A2(_elm_lang$core$Platform$sendToSelf, router, _p1))); - return A2(_elm_lang$core$Task$andThen, spawnRest, spawnTimer); - } - }); -var _elm_lang$core$Time$addMySub = F2( - function (_p2, state) { - var _p3 = _p2; - var _p6 = _p3._1; - var _p5 = _p3._0; - var _p4 = A2(_elm_lang$core$Dict$get, _p5, state); - if (_p4.ctor === 'Nothing') { - return A3( - _elm_lang$core$Dict$insert, - _p5, - { - ctor: '::', - _0: _p6, - _1: {ctor: '[]'} - }, - state); - } else { - return A3( - _elm_lang$core$Dict$insert, - _p5, - {ctor: '::', _0: _p6, _1: _p4._0}, - state); - } - }); -var _elm_lang$core$Time$inMilliseconds = function (t) { - return t; -}; -var _elm_lang$core$Time$millisecond = 1; -var _elm_lang$core$Time$second = 1000 * _elm_lang$core$Time$millisecond; -var _elm_lang$core$Time$minute = 60 * _elm_lang$core$Time$second; -var _elm_lang$core$Time$hour = 60 * _elm_lang$core$Time$minute; -var _elm_lang$core$Time$inHours = function (t) { - return t / _elm_lang$core$Time$hour; -}; -var _elm_lang$core$Time$inMinutes = function (t) { - return t / _elm_lang$core$Time$minute; -}; -var _elm_lang$core$Time$inSeconds = function (t) { - return t / _elm_lang$core$Time$second; -}; -var _elm_lang$core$Time$now = _elm_lang$core$Native_Time.now; -var _elm_lang$core$Time$onSelfMsg = F3( - function (router, interval, state) { - var _p7 = A2(_elm_lang$core$Dict$get, interval, state.taggers); - if (_p7.ctor === 'Nothing') { - return _elm_lang$core$Task$succeed(state); - } else { - var tellTaggers = function (time) { - return _elm_lang$core$Task$sequence( - A2( - _elm_lang$core$List$map, - function (tagger) { - return A2( - _elm_lang$core$Platform$sendToApp, - router, - tagger(time)); - }, - _p7._0)); - }; - return A2( - _elm_lang$core$Task$andThen, - function (_p8) { - return _elm_lang$core$Task$succeed(state); - }, - A2(_elm_lang$core$Task$andThen, tellTaggers, _elm_lang$core$Time$now)); - } - }); -var _elm_lang$core$Time$subscription = _elm_lang$core$Native_Platform.leaf('Time'); -var _elm_lang$core$Time$State = F2( - function (a, b) { - return {taggers: a, processes: b}; - }); -var _elm_lang$core$Time$init = _elm_lang$core$Task$succeed( - A2(_elm_lang$core$Time$State, _elm_lang$core$Dict$empty, _elm_lang$core$Dict$empty)); -var _elm_lang$core$Time$onEffects = F3( - function (router, subs, _p9) { - var _p10 = _p9; - var rightStep = F3( - function (_p12, id, _p11) { - var _p13 = _p11; - return { - ctor: '_Tuple3', - _0: _p13._0, - _1: _p13._1, - _2: A2( - _elm_lang$core$Task$andThen, - function (_p14) { - return _p13._2; - }, - _elm_lang$core$Native_Scheduler.kill(id)) - }; - }); - var bothStep = F4( - function (interval, taggers, id, _p15) { - var _p16 = _p15; - return { - ctor: '_Tuple3', - _0: _p16._0, - _1: A3(_elm_lang$core$Dict$insert, interval, id, _p16._1), - _2: _p16._2 - }; - }); - var leftStep = F3( - function (interval, taggers, _p17) { - var _p18 = _p17; - return { - ctor: '_Tuple3', - _0: {ctor: '::', _0: interval, _1: _p18._0}, - _1: _p18._1, - _2: _p18._2 - }; - }); - var newTaggers = A3(_elm_lang$core$List$foldl, _elm_lang$core$Time$addMySub, _elm_lang$core$Dict$empty, subs); - var _p19 = A6( - _elm_lang$core$Dict$merge, - leftStep, - bothStep, - rightStep, - newTaggers, - _p10.processes, - { - ctor: '_Tuple3', - _0: {ctor: '[]'}, - _1: _elm_lang$core$Dict$empty, - _2: _elm_lang$core$Task$succeed( - {ctor: '_Tuple0'}) - }); - var spawnList = _p19._0; - var existingDict = _p19._1; - var killTask = _p19._2; - return A2( - _elm_lang$core$Task$andThen, - function (newProcesses) { - return _elm_lang$core$Task$succeed( - A2(_elm_lang$core$Time$State, newTaggers, newProcesses)); - }, - A2( - _elm_lang$core$Task$andThen, - function (_p20) { - return A3(_elm_lang$core$Time$spawnHelp, router, spawnList, existingDict); - }, - killTask)); - }); -var _elm_lang$core$Time$Every = F2( - function (a, b) { - return {ctor: 'Every', _0: a, _1: b}; - }); -var _elm_lang$core$Time$every = F2( - function (interval, tagger) { - return _elm_lang$core$Time$subscription( - A2(_elm_lang$core$Time$Every, interval, tagger)); - }); -var _elm_lang$core$Time$subMap = F2( - function (f, _p21) { - var _p22 = _p21; - return A2( - _elm_lang$core$Time$Every, - _p22._0, - function (_p23) { - return f( - _p22._1(_p23)); - }); - }); -_elm_lang$core$Native_Platform.effectManagers['Time'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Time$init, onEffects: _elm_lang$core$Time$onEffects, onSelfMsg: _elm_lang$core$Time$onSelfMsg, tag: 'sub', subMap: _elm_lang$core$Time$subMap}; - var _elm_lang$core$Set$foldr = F3( function (f, b, _p0) { var _p1 = _p0; @@ -7635,6 +7223,418 @@ return { }; }(); +var _elm_lang$core$Task$onError = _elm_lang$core$Native_Scheduler.onError; +var _elm_lang$core$Task$andThen = _elm_lang$core$Native_Scheduler.andThen; +var _elm_lang$core$Task$spawnCmd = F2( + function (router, _p0) { + var _p1 = _p0; + return _elm_lang$core$Native_Scheduler.spawn( + A2( + _elm_lang$core$Task$andThen, + _elm_lang$core$Platform$sendToApp(router), + _p1._0)); + }); +var _elm_lang$core$Task$fail = _elm_lang$core$Native_Scheduler.fail; +var _elm_lang$core$Task$mapError = F2( + function (convert, task) { + return A2( + _elm_lang$core$Task$onError, + function (_p2) { + return _elm_lang$core$Task$fail( + convert(_p2)); + }, + task); + }); +var _elm_lang$core$Task$succeed = _elm_lang$core$Native_Scheduler.succeed; +var _elm_lang$core$Task$map = F2( + function (func, taskA) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return _elm_lang$core$Task$succeed( + func(a)); + }, + taskA); + }); +var _elm_lang$core$Task$map2 = F3( + function (func, taskA, taskB) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return _elm_lang$core$Task$succeed( + A2(func, a, b)); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$map3 = F4( + function (func, taskA, taskB, taskC) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return A2( + _elm_lang$core$Task$andThen, + function (c) { + return _elm_lang$core$Task$succeed( + A3(func, a, b, c)); + }, + taskC); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$map4 = F5( + function (func, taskA, taskB, taskC, taskD) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return A2( + _elm_lang$core$Task$andThen, + function (c) { + return A2( + _elm_lang$core$Task$andThen, + function (d) { + return _elm_lang$core$Task$succeed( + A4(func, a, b, c, d)); + }, + taskD); + }, + taskC); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$map5 = F6( + function (func, taskA, taskB, taskC, taskD, taskE) { + return A2( + _elm_lang$core$Task$andThen, + function (a) { + return A2( + _elm_lang$core$Task$andThen, + function (b) { + return A2( + _elm_lang$core$Task$andThen, + function (c) { + return A2( + _elm_lang$core$Task$andThen, + function (d) { + return A2( + _elm_lang$core$Task$andThen, + function (e) { + return _elm_lang$core$Task$succeed( + A5(func, a, b, c, d, e)); + }, + taskE); + }, + taskD); + }, + taskC); + }, + taskB); + }, + taskA); + }); +var _elm_lang$core$Task$sequence = function (tasks) { + var _p3 = tasks; + if (_p3.ctor === '[]') { + return _elm_lang$core$Task$succeed( + {ctor: '[]'}); + } else { + return A3( + _elm_lang$core$Task$map2, + F2( + function (x, y) { + return {ctor: '::', _0: x, _1: y}; + }), + _p3._0, + _elm_lang$core$Task$sequence(_p3._1)); + } +}; +var _elm_lang$core$Task$onEffects = F3( + function (router, commands, state) { + return A2( + _elm_lang$core$Task$map, + function (_p4) { + return {ctor: '_Tuple0'}; + }, + _elm_lang$core$Task$sequence( + A2( + _elm_lang$core$List$map, + _elm_lang$core$Task$spawnCmd(router), + commands))); + }); +var _elm_lang$core$Task$init = _elm_lang$core$Task$succeed( + {ctor: '_Tuple0'}); +var _elm_lang$core$Task$onSelfMsg = F3( + function (_p7, _p6, _p5) { + return _elm_lang$core$Task$succeed( + {ctor: '_Tuple0'}); + }); +var _elm_lang$core$Task$command = _elm_lang$core$Native_Platform.leaf('Task'); +var _elm_lang$core$Task$Perform = function (a) { + return {ctor: 'Perform', _0: a}; +}; +var _elm_lang$core$Task$perform = F2( + function (toMessage, task) { + return _elm_lang$core$Task$command( + _elm_lang$core$Task$Perform( + A2(_elm_lang$core$Task$map, toMessage, task))); + }); +var _elm_lang$core$Task$attempt = F2( + function (resultToMessage, task) { + return _elm_lang$core$Task$command( + _elm_lang$core$Task$Perform( + A2( + _elm_lang$core$Task$onError, + function (_p8) { + return _elm_lang$core$Task$succeed( + resultToMessage( + _elm_lang$core$Result$Err(_p8))); + }, + A2( + _elm_lang$core$Task$andThen, + function (_p9) { + return _elm_lang$core$Task$succeed( + resultToMessage( + _elm_lang$core$Result$Ok(_p9))); + }, + task)))); + }); +var _elm_lang$core$Task$cmdMap = F2( + function (tagger, _p10) { + var _p11 = _p10; + return _elm_lang$core$Task$Perform( + A2(_elm_lang$core$Task$map, tagger, _p11._0)); + }); +_elm_lang$core$Native_Platform.effectManagers['Task'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Task$init, onEffects: _elm_lang$core$Task$onEffects, onSelfMsg: _elm_lang$core$Task$onSelfMsg, tag: 'cmd', cmdMap: _elm_lang$core$Task$cmdMap}; + +//import Native.Scheduler // + +var _elm_lang$core$Native_Time = function() { + +var now = _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) +{ + callback(_elm_lang$core$Native_Scheduler.succeed(Date.now())); +}); + +function setInterval_(interval, task) +{ + return _elm_lang$core$Native_Scheduler.nativeBinding(function(callback) + { + var id = setInterval(function() { + _elm_lang$core$Native_Scheduler.rawSpawn(task); + }, interval); + + return function() { clearInterval(id); }; + }); +} + +return { + now: now, + setInterval_: F2(setInterval_) +}; + +}(); +var _elm_lang$core$Time$setInterval = _elm_lang$core$Native_Time.setInterval_; +var _elm_lang$core$Time$spawnHelp = F3( + function (router, intervals, processes) { + var _p0 = intervals; + if (_p0.ctor === '[]') { + return _elm_lang$core$Task$succeed(processes); + } else { + var _p1 = _p0._0; + var spawnRest = function (id) { + return A3( + _elm_lang$core$Time$spawnHelp, + router, + _p0._1, + A3(_elm_lang$core$Dict$insert, _p1, id, processes)); + }; + var spawnTimer = _elm_lang$core$Native_Scheduler.spawn( + A2( + _elm_lang$core$Time$setInterval, + _p1, + A2(_elm_lang$core$Platform$sendToSelf, router, _p1))); + return A2(_elm_lang$core$Task$andThen, spawnRest, spawnTimer); + } + }); +var _elm_lang$core$Time$addMySub = F2( + function (_p2, state) { + var _p3 = _p2; + var _p6 = _p3._1; + var _p5 = _p3._0; + var _p4 = A2(_elm_lang$core$Dict$get, _p5, state); + if (_p4.ctor === 'Nothing') { + return A3( + _elm_lang$core$Dict$insert, + _p5, + { + ctor: '::', + _0: _p6, + _1: {ctor: '[]'} + }, + state); + } else { + return A3( + _elm_lang$core$Dict$insert, + _p5, + {ctor: '::', _0: _p6, _1: _p4._0}, + state); + } + }); +var _elm_lang$core$Time$inMilliseconds = function (t) { + return t; +}; +var _elm_lang$core$Time$millisecond = 1; +var _elm_lang$core$Time$second = 1000 * _elm_lang$core$Time$millisecond; +var _elm_lang$core$Time$minute = 60 * _elm_lang$core$Time$second; +var _elm_lang$core$Time$hour = 60 * _elm_lang$core$Time$minute; +var _elm_lang$core$Time$inHours = function (t) { + return t / _elm_lang$core$Time$hour; +}; +var _elm_lang$core$Time$inMinutes = function (t) { + return t / _elm_lang$core$Time$minute; +}; +var _elm_lang$core$Time$inSeconds = function (t) { + return t / _elm_lang$core$Time$second; +}; +var _elm_lang$core$Time$now = _elm_lang$core$Native_Time.now; +var _elm_lang$core$Time$onSelfMsg = F3( + function (router, interval, state) { + var _p7 = A2(_elm_lang$core$Dict$get, interval, state.taggers); + if (_p7.ctor === 'Nothing') { + return _elm_lang$core$Task$succeed(state); + } else { + var tellTaggers = function (time) { + return _elm_lang$core$Task$sequence( + A2( + _elm_lang$core$List$map, + function (tagger) { + return A2( + _elm_lang$core$Platform$sendToApp, + router, + tagger(time)); + }, + _p7._0)); + }; + return A2( + _elm_lang$core$Task$andThen, + function (_p8) { + return _elm_lang$core$Task$succeed(state); + }, + A2(_elm_lang$core$Task$andThen, tellTaggers, _elm_lang$core$Time$now)); + } + }); +var _elm_lang$core$Time$subscription = _elm_lang$core$Native_Platform.leaf('Time'); +var _elm_lang$core$Time$State = F2( + function (a, b) { + return {taggers: a, processes: b}; + }); +var _elm_lang$core$Time$init = _elm_lang$core$Task$succeed( + A2(_elm_lang$core$Time$State, _elm_lang$core$Dict$empty, _elm_lang$core$Dict$empty)); +var _elm_lang$core$Time$onEffects = F3( + function (router, subs, _p9) { + var _p10 = _p9; + var rightStep = F3( + function (_p12, id, _p11) { + var _p13 = _p11; + return { + ctor: '_Tuple3', + _0: _p13._0, + _1: _p13._1, + _2: A2( + _elm_lang$core$Task$andThen, + function (_p14) { + return _p13._2; + }, + _elm_lang$core$Native_Scheduler.kill(id)) + }; + }); + var bothStep = F4( + function (interval, taggers, id, _p15) { + var _p16 = _p15; + return { + ctor: '_Tuple3', + _0: _p16._0, + _1: A3(_elm_lang$core$Dict$insert, interval, id, _p16._1), + _2: _p16._2 + }; + }); + var leftStep = F3( + function (interval, taggers, _p17) { + var _p18 = _p17; + return { + ctor: '_Tuple3', + _0: {ctor: '::', _0: interval, _1: _p18._0}, + _1: _p18._1, + _2: _p18._2 + }; + }); + var newTaggers = A3(_elm_lang$core$List$foldl, _elm_lang$core$Time$addMySub, _elm_lang$core$Dict$empty, subs); + var _p19 = A6( + _elm_lang$core$Dict$merge, + leftStep, + bothStep, + rightStep, + newTaggers, + _p10.processes, + { + ctor: '_Tuple3', + _0: {ctor: '[]'}, + _1: _elm_lang$core$Dict$empty, + _2: _elm_lang$core$Task$succeed( + {ctor: '_Tuple0'}) + }); + var spawnList = _p19._0; + var existingDict = _p19._1; + var killTask = _p19._2; + return A2( + _elm_lang$core$Task$andThen, + function (newProcesses) { + return _elm_lang$core$Task$succeed( + A2(_elm_lang$core$Time$State, newTaggers, newProcesses)); + }, + A2( + _elm_lang$core$Task$andThen, + function (_p20) { + return A3(_elm_lang$core$Time$spawnHelp, router, spawnList, existingDict); + }, + killTask)); + }); +var _elm_lang$core$Time$Every = F2( + function (a, b) { + return {ctor: 'Every', _0: a, _1: b}; + }); +var _elm_lang$core$Time$every = F2( + function (interval, tagger) { + return _elm_lang$core$Time$subscription( + A2(_elm_lang$core$Time$Every, interval, tagger)); + }); +var _elm_lang$core$Time$subMap = F2( + function (f, _p21) { + var _p22 = _p21; + return A2( + _elm_lang$core$Time$Every, + _p22._0, + function (_p23) { + return f( + _p22._1(_p23)); + }); + }); +_elm_lang$core$Native_Platform.effectManagers['Time'] = {pkg: 'elm-lang/core', init: _elm_lang$core$Time$init, onEffects: _elm_lang$core$Time$onEffects, onSelfMsg: _elm_lang$core$Time$onSelfMsg, tag: 'sub', subMap: _elm_lang$core$Time$subMap}; + var _elm_lang$core$Date$millisecond = _elm_lang$core$Native_Date.millisecond; var _elm_lang$core$Date$second = _elm_lang$core$Native_Date.second; var _elm_lang$core$Date$minute = _elm_lang$core$Native_Date.minute; @@ -15825,6 +15825,32 @@ var _user$project$Views_EventDetail$eventInstanceItem = F2( }); var _user$project$Views_EventDetail$eventMetaDataSidebar = F3( function (event, eventInstances, model) { + var feedbackUrl = { + ctor: '::', + _0: A2( + _elm_lang$html$Html$a, + { + ctor: '::', + _0: _elm_lang$html$Html_Attributes$href( + A2( + _elm_lang$core$Basics_ops['++'], + 'https://bornhack.dk/', + A2( + _elm_lang$core$Basics_ops['++'], + model.flags.camp_slug, + A2( + _elm_lang$core$Basics_ops['++'], + '/program/', + A2(_elm_lang$core$Basics_ops['++'], event.slug, '/feedback/create/'))))), + _1: {ctor: '[]'} + }, + { + ctor: '::', + _0: _elm_lang$html$Html$text('Give feedback'), + _1: {ctor: '[]'} + }), + _1: {ctor: '[]'} + }; var eventInstanceMetaData = function () { var _p3 = eventInstances; if ((_p3.ctor === '::') && (_p3._1.ctor === '[]')) { @@ -15950,7 +15976,7 @@ var _user$project$Views_EventDetail$eventMetaDataSidebar = F3( _1: {ctor: '[]'} } }, - eventInstanceMetaData)); + A2(_elm_lang$core$Basics_ops['++'], eventInstanceMetaData, feedbackUrl))); }); var _user$project$Views_EventDetail$getSpeakersFromSlugs = F3( function (speakers, slugs, collectedSpeakers) { diff --git a/src/program/templates/event_list.html b/src/program/templates/event_list.html index 3389fe50..71c44cf2 100644 --- a/src/program/templates/event_list.html +++ b/src/program/templates/event_list.html @@ -1,4 +1,5 @@ {% extends 'program_base.html' %} +{% load program %} {% block program_content %} {% if event_list %} @@ -14,6 +15,9 @@ Title Speakers Scheduled + {% if not user.is_anonymous %} + Feedback + {% endif %} @@ -26,7 +30,7 @@ - {{ event.title }} + {{ event.title }} {% for speaker in event.speakers.all %} @@ -42,6 +46,9 @@ No instances scheduled yet {% endfor %} + {% if not user.is_anonymous %} + {% feedbackbutton %} + {% endif %} {% endif %} {% endfor %} diff --git a/src/program/templates/eventfeedback_delete.html b/src/program/templates/eventfeedback_delete.html new file mode 100644 index 00000000..54cd3d94 --- /dev/null +++ b/src/program/templates/eventfeedback_delete.html @@ -0,0 +1,18 @@ +{% extends 'program_base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + +{% block program_content %} +

Delete Your Feedback?

+ +{% include 'includes/eventfeedback_detail_panel.html' %} + +
+ {% csrf_token %} + + Cancel +
+{% endblock program_content %} diff --git a/src/program/templates/eventfeedback_detail.html b/src/program/templates/eventfeedback_detail.html new file mode 100644 index 00000000..0898c6b7 --- /dev/null +++ b/src/program/templates/eventfeedback_detail.html @@ -0,0 +1,7 @@ +{% extends 'program_base.html' %} +{% load commonmark %} + +{% block program_content %} +{% include 'includes/eventfeedback_detail_panel.html' with buttoninclude="includes/eventfeedback_buttons.html"%} +{% endblock program_content %} + diff --git a/src/program/templates/eventfeedback_form.html b/src/program/templates/eventfeedback_form.html new file mode 100644 index 00000000..86fc80b7 --- /dev/null +++ b/src/program/templates/eventfeedback_form.html @@ -0,0 +1,18 @@ +{% extends 'program_base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + +{% block program_content %} +

{% if request.resolver_match.url_name == "eventfeedback_update" %}Update{% else %}Submit{% endif %} Feedback for {{ camp.title }} event: {{ event.title }}

+
+ {% csrf_token %} + {% bootstrap_form form %} + + Cancel + +
+ +{% endblock program_content %} diff --git a/src/program/templates/eventfeedback_list.html b/src/program/templates/eventfeedback_list.html new file mode 100644 index 00000000..9a55e93a --- /dev/null +++ b/src/program/templates/eventfeedback_list.html @@ -0,0 +1,11 @@ +{% extends 'program_base.html' %} +{% load program %} + +{% block program_content %} +

Feedback List

+

This is a list of all the approved feedback for your {{ camp.title }} event {{ event.title }}. We currently have feedback from {{ eventfeedback_list|length }} users.

+ +{% for eventfeedback in eventfeedback_list %} +{% include 'includes/eventfeedback_detail_panel.html' %} +{% endfor %} +{% endblock program_content %} diff --git a/src/program/templates/includes/eventfeedback_buttons.html b/src/program/templates/includes/eventfeedback_buttons.html new file mode 100644 index 00000000..decd66df --- /dev/null +++ b/src/program/templates/includes/eventfeedback_buttons.html @@ -0,0 +1,6 @@ + Back to Event + + Update Feedback + + Delete Feedback + diff --git a/src/program/templates/includes/eventfeedback_detail_panel.html b/src/program/templates/includes/eventfeedback_detail_panel.html new file mode 100644 index 00000000..f9d0ab15 --- /dev/null +++ b/src/program/templates/includes/eventfeedback_detail_panel.html @@ -0,0 +1,57 @@ +{% load bootstrap3 %} +{% load commonmark %} +
+
+ Feedback for Event: {{ event.title }} +
+
+ + {% if request.resolver_match.url_name == "approve_eventfeedback" %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Username (feedback submitter){{ eventfeedback.user.username }}
Were Your Expectations Fulfilled?{{ eventfeedback.expectations_fulfilled|yesno }}
Would You Attend the Same Speaker Again?{{ eventfeedback.attend_speaker_again|yesno }}
Rating (0-5)?{{ eventfeedback.rating }}/5
Created{{ eventfeedback.created }}
Updated{{ eventfeedback.updated|default:"N/A" }}
Approved{{ eventfeedback.approved|yesno }}
Comment{{ eventfeedback.comment|default:"N/A"|untrustedcommonmark }}
+ {% if form %} + {% bootstrap_form form %} + {% elif buttoninclude %} + {% include buttoninclude %} + {% endif %} +
+
+ diff --git a/src/program/templates/schedule_event_detail.html b/src/program/templates/schedule_event_detail.html index 2e042d85..a3ad94e6 100644 --- a/src/program/templates/schedule_event_detail.html +++ b/src/program/templates/schedule_event_detail.html @@ -1,6 +1,6 @@ {% extends 'program_base.html' %} {% load commonmark %} - +{% load program %} {% block program_content %}
@@ -14,7 +14,7 @@
-
{{ event.title }}
+
{{ event.title }}{% feedbackbutton %}

{{ event.abstract|untrustedcommonmark }} diff --git a/src/program/templates/speaker_detail.html b/src/program/templates/speaker_detail.html index 387a814a..70ca767e 100644 --- a/src/program/templates/speaker_detail.html +++ b/src/program/templates/speaker_detail.html @@ -30,7 +30,7 @@ {{ event.event_type.name }} - {{ event.title }} + {{ event.title }} {{ event.abstract|untrustedcommonmark }} diff --git a/src/program/templatetags/__init__.py b/src/program/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/program/templatetags/program.py b/src/program/templatetags/program.py new file mode 100644 index 00000000..f687c0a2 --- /dev/null +++ b/src/program/templatetags/program.py @@ -0,0 +1,50 @@ +from django import template +from django.urls import reverse +from django.utils.safestring import mark_safe + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def feedbackbutton(context): + """ A templatetag to show a suitable button for EventFeedback """ + + if context.request.user.is_anonymous: + return None + + event = context["event"] + if event.proposal and event.proposal.user == context.request.user: + # current user is the event owner, show a link to EventFeedbackList + return mark_safe( + " Read Feedback (%s)" + % ( + reverse( + "program:eventfeedback_list", + kwargs={"camp_slug": event.camp.slug, "event_slug": event.slug}, + ), + event.feedbacks.filter(approved=True).count(), + ) + ) + # FIXME: for some reason this triggers a lookup even though all feedbacks have been prefetched.. + elif event.feedbacks.filter(user=context.request.user).exists(): + # this user already submitted feedback for this event, show a link to DetailView + return mark_safe( + " Change Feedback" + % ( + reverse( + "program:eventfeedback_detail", + kwargs={"camp_slug": event.camp.slug, "event_slug": event.slug}, + ) + ) + ) + else: + # this user has not submitted feedback yet, show a link to CreateView + return mark_safe( + " Add Feedback" + % ( + reverse( + "program:eventfeedback_create", + kwargs={"camp_slug": event.camp.slug, "event_slug": event.slug}, + ) + ) + ) diff --git a/src/program/urls.py b/src/program/urls.py index fc133e3a..0a0e2311 100644 --- a/src/program/urls.py +++ b/src/program/urls.py @@ -15,6 +15,11 @@ from .views import ( EventProposalSelectPersonView, EventProposalTypeSelectView, EventProposalUpdateView, + FeedbackCreateView, + FeedbackDeleteView, + FeedbackDetailView, + FeedbackListView, + FeedbackUpdateView, ICSView, NoScriptScheduleView, ProgramControlCenter, @@ -151,6 +156,7 @@ urlpatterns = [ EventProposalRemovePersonView.as_view(), name="eventproposal_removeperson", ), + # event url views path( "/add_url/", UrlCreateView.as_view(), @@ -196,6 +202,45 @@ urlpatterns = [ name="call_for_participation", ), path("calendar", ICSView.as_view(), name="ics_calendar"), - # this must be the last URL here or the regex will overrule the others - path("/", EventDetailView.as_view(), name="event_detail"), + # this must be the last URL here or the slug will overrule the others + path( + "/", + include( + [ + path("", EventDetailView.as_view(), name="event_detail"), + path( + "feedback/", + include( + [ + path( + "", + FeedbackListView.as_view(), + name="eventfeedback_list", + ), + path( + "show/", + FeedbackDetailView.as_view(), + name="eventfeedback_detail", + ), + path( + "create/", + FeedbackCreateView.as_view(), + name="eventfeedback_create", + ), + path( + "update/", + FeedbackUpdateView.as_view(), + name="eventfeedback_update", + ), + path( + "delete/", + FeedbackDeleteView.as_view(), + name="eventfeedback_delete", + ), + ] + ), + ), + ] + ), + ), ] diff --git a/src/program/views.py b/src/program/views.py index 225b81aa..bb5b275e 100644 --- a/src/program/views.py +++ b/src/program/views.py @@ -2,6 +2,8 @@ import logging from collections import OrderedDict import icalendar +from camps.mixins import CampViewMixin +from django import forms from django.conf import settings from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required @@ -13,8 +15,8 @@ from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator from django.views.generic import DetailView, ListView, TemplateView, View from django.views.generic.edit import CreateView, DeleteView, UpdateView - -from camps.mixins import CampViewMixin +from utils.middleware import RedirectException +from utils.mixins import UserIsObjectOwnerMixin from . import models from .email import ( @@ -28,6 +30,8 @@ from .mixins import ( EnsureCFPOpenMixin, EnsureUserOwnsProposalMixin, EnsureWritableCampMixin, + EventFeedbackViewMixin, + EventViewMixin, UrlViewMixin, ) from .multiform import MultiModelForm @@ -762,6 +766,12 @@ class SpeakerListView(CampViewMixin, ListView): model = models.Speaker template_name = "speaker_list.html" + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + qs = qs.prefetch_related("events") + qs = qs.prefetch_related("events__event_type") + return qs + ################################################################################################### # event views @@ -771,10 +781,16 @@ class EventListView(CampViewMixin, ListView): model = models.Event template_name = "event_list.html" + def get_queryset(self, *args, **kwargs): + qs = super().get_queryset(*args, **kwargs) + qs = qs.prefetch_related("event_type", "track", "instances", "speakers") + return qs + class EventDetailView(CampViewMixin, DetailView): model = models.Event template_name = "schedule_event_detail.html" + slug_url_kwarg = "event_slug" ################################################################################################### @@ -1035,3 +1051,120 @@ class UrlDeleteView( return redirect( reverse_lazy("program:proposal_list", kwargs={"camp_slug": self.camp.slug}) ) + + +################################################################################################### +# Feedback views + + +class FeedbackListView(LoginRequiredMixin, EventViewMixin, ListView): + """ + The FeedbackListView is used by the event owner to see approved Feedback for the Event. + """ + + model = models.EventFeedback + template_name = "eventfeedback_list.html" + + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + if not self.event.proposal or not self.event.proposal.user == self.request.user: + messages.error(self.request, "Only the event owner can read feedback!") + raise RedirectException( + reverse( + "program:event_detail", + kwargs={"camp_slug": self.camp.slug, "event_slug": self.event.slug}, + ) + ) + + def get_queryset(self, *args, **kwargs): + return models.EventFeedback.objects.filter(event=self.event, approved=True) + + +class FeedbackCreateView(LoginRequiredMixin, EventViewMixin, CreateView): + """ + Used by users to create Feedback for an Event. Available to all logged in users. + """ + + model = models.EventFeedback + fields = ["expectations_fulfilled", "attend_speaker_again", "rating", "comment"] + template_name = "eventfeedback_form.html" + + def setup(self, *args, **kwargs): + super().setup(*args, **kwargs) + if models.EventFeedback.objects.filter( + event=self.event, user=self.request.user + ).exists(): + raise RedirectException( + reverse( + "program:eventfeedback_detail", + kwargs={"camp_slug": self.camp.slug, "event_slug": self.event.slug}, + ) + ) + + def get_form(self, *args, **kwargs): + form = super().get_form(*args, **kwargs) + form.fields["expectations_fulfilled"].widget = forms.RadioSelect( + choices=models.EventFeedback.YESNO_CHOICES, + ) + form.fields["attend_speaker_again"].widget = forms.RadioSelect( + choices=models.EventFeedback.YESNO_CHOICES, + ) + form.fields["rating"].widget = forms.RadioSelect( + choices=models.EventFeedback.RATING_CHOICES, + ) + return form + + def form_valid(self, form): + feedback = form.save(commit=False) + feedback.user = self.request.user + feedback.event = self.event + feedback.save() + messages.success( + self.request, "Your feedback was submitted, it is now pending approval." + ) + return redirect(feedback.get_absolute_url()) + + +class FeedbackDetailView( + LoginRequiredMixin, EventFeedbackViewMixin, UserIsObjectOwnerMixin, DetailView +): + """ + Used by the EventFeedback owner to see their own feedback. + """ + + model = models.EventFeedback + template_name = "eventfeedback_detail.html" + + +class FeedbackUpdateView( + LoginRequiredMixin, EventFeedbackViewMixin, UserIsObjectOwnerMixin, UpdateView +): + """ + Used by the EventFeedback owner to update their feedback. + """ + + model = models.EventFeedback + fields = ["expectations_fulfilled", "attend_speaker_again", "rating", "comment"] + template_name = "eventfeedback_form.html" + + def form_valid(self, form): + feedback = form.save(commit=False) + feedback.approved = False + feedback.save() + messages.success(self.request, "Your feedback was updated") + return redirect(feedback.get_absolute_url()) + + +class FeedbackDeleteView( + LoginRequiredMixin, EventFeedbackViewMixin, UserIsObjectOwnerMixin, DeleteView +): + """ + Used by the EventFeedback owner to delete their own feedback. + """ + + model = models.EventFeedback + template_name = "eventfeedback_delete.html" + + def get_success_url(self): + messages.success(self.request, "Your feedback was deleted") + return self.event.get_absolute_url() diff --git a/src/rideshare/views.py b/src/rideshare/views.py index dd6e1fa7..96c27120 100644 --- a/src/rideshare/views.py +++ b/src/rideshare/views.py @@ -1,6 +1,6 @@ from django import forms from django.contrib import messages -from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponseRedirect from django.urls import reverse from django.views.generic import ( @@ -13,6 +13,7 @@ from django.views.generic import ( from camps.mixins import CampViewMixin from utils.email import add_outgoing_email +from utils.mixins import UserIsObjectOwnerMixin from .models import Ride @@ -91,12 +92,7 @@ class RideCreate(LoginRequiredMixin, CampViewMixin, CreateView): return HttpResponseRedirect(self.get_success_url()) -class IsRideOwnerMixin(UserPassesTestMixin): - def test_func(self): - return self.get_object().user == self.request.user - - -class RideUpdate(LoginRequiredMixin, CampViewMixin, IsRideOwnerMixin, UpdateView): +class RideUpdate(LoginRequiredMixin, CampViewMixin, UserIsObjectOwnerMixin, UpdateView): model = Ride fields = [ "author", @@ -109,7 +105,7 @@ class RideUpdate(LoginRequiredMixin, CampViewMixin, IsRideOwnerMixin, UpdateView ] -class RideDelete(LoginRequiredMixin, CampViewMixin, IsRideOwnerMixin, DeleteView): +class RideDelete(LoginRequiredMixin, CampViewMixin, UserIsObjectOwnerMixin, DeleteView): model = Ride def get_success_url(self): diff --git a/src/utils/middleware.py b/src/utils/middleware.py new file mode 100644 index 00000000..b649db26 --- /dev/null +++ b/src/utils/middleware.py @@ -0,0 +1,30 @@ +from django.shortcuts import redirect + + +class RedirectException(Exception): + """ + An exception class meant to be used to redirect from places where + we cannot just return a HTTPResponse directly (like view setup() methods) + """ + + def __init__(self, url): + self.url = url + + +class RedirectExceptionMiddleware: + """ + A simple middleware to catch exceptions of type RedirectException + and redirect to the url + """ + + def __init__(self, get_response): + self.get_response = get_response + + def process_exception(self, request, exception): + if isinstance(exception, RedirectException): + if hasattr(exception, "url"): + return redirect(exception.url) + + def __call__(self, request): + response = self.get_response(request) + return response diff --git a/src/utils/mixins.py b/src/utils/mixins.py index 038a0268..f9571a6d 100644 --- a/src/utils/mixins.py +++ b/src/utils/mixins.py @@ -1,5 +1,5 @@ from django.contrib import messages -from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.http import HttpResponseForbidden @@ -25,3 +25,8 @@ class RaisePermissionRequiredMixin(PermissionRequiredMixin): """ raise_exception = True + + +class UserIsObjectOwnerMixin(UserPassesTestMixin): + def test_func(self): + return self.get_object().user == self.request.user diff --git a/src/utils/templatetags/bornhack.py b/src/utils/templatetags/bornhack.py index 4065f581..b4827c31 100644 --- a/src/utils/templatetags/bornhack.py +++ b/src/utils/templatetags/bornhack.py @@ -4,6 +4,11 @@ from django.utils.safestring import mark_safe register = template.Library() +@register.filter(name="zip") +def zip_lists(a, b): + return zip(a, b) + + @register.filter() def truefalseicon(value): """ A templatetag to show a green checkbox or red x depending on True/False value """