Skip to content

Commit

Permalink
Fix #1704: Allow anonymous feedback (#1770)
Browse files Browse the repository at this point in the history
Co-authored-by: Niklas Mohrin <dev@niklasmohrin.de>
  • Loading branch information
Kakadus and niklasmohrin authored Aug 8, 2022
1 parent 70e1746 commit 6aa3a5f
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 67 deletions.
4 changes: 4 additions & 0 deletions evap/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ def slogan(request):

def debug(request):
return {"debug": settings.DEBUG}


def allow_anonymous_feedback_messages(request):
return {"allow_anonymous_feedback_messages": settings.ALLOW_ANONYMOUS_FEEDBACK_MESSAGES}
77 changes: 20 additions & 57 deletions evap/evaluation/templates/contact_modal.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{% load static %}

<div class="modal fade" id="successMessageModal_{{ modal_id }}" tabindex="-1" role="dialog" aria-labelledby="successMessageModalLabel_{{ modal_id }}" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
Expand All @@ -21,19 +23,22 @@ <h5 class="modal-title" id="{{ modal_id }}Label">{{ title }}</h5>
<form id="{{ modal_id }}Form">
<div class="modal-body">
{{ teaser }}
<br /><br />
<div class="mb-3">
<label for="{{ modal_id }}SenderName" class="control-label">{% trans 'Sender' %}:</label>
<input type="text" class="form-control mb-3" id="{{ modal_id }}SenderName" disabled value="{{ user.full_name }}" />
</div>
<div class="mb-3">
<label for="{{ modal_id }}Subject" class="control-label">{% trans 'Subject' %}:</label>
<input type="text" class="form-control" id="{{ modal_id }}Subject" disabled value="{{ title }}" />
</div>
<div class="mb-3">
<label for="{{ modal_id }}MessageText" class="control-label">{% trans 'Message' %}:</label>
<textarea autofocus class="form-control" id="{{ modal_id }}MessageText" style="resize:vertical;min-height:150px;"></textarea>
<div class="modal-grid">
<label for="{{ modal_id }}SenderName" class="control-label my-auto pe-4">{% trans 'Sender' %}</label>
{% if modal_id == "feedbackModal" and allow_anonymous_feedback_messages %}
<div class="btn-group text-wrap" role="group" aria-label="{{ modal_id }} Radio Group">
<input type="radio" class="btn-check" name="{{ modal_id }}RadioGroup" id="{{ modal_id }}SenderName" checked>
<label class="btn btn-sm btn-outline-primary text-break" for="{{ modal_id }}SenderName">{{ user.full_name }}</label>
<input type="radio" class="btn-check" name="{{ modal_id }}RadioGroup" id="{{ modal_id }}AnonymousName">
<label class="btn btn-sm btn-outline-primary" for="{{ modal_id }}AnonymousName">{% trans 'Anonymous' %}</label>
</div>
{% else %}
<input type="text" class="form-control mx-auto text-break" id="{{ modal_id }}SenderName" disabled value="{{ user.full_name }}"/>
{% endif %}
<label for="{{ modal_id }}Subject" class="control-label my-auto pe-4 text-break">{% trans 'Subject' %}</label>
<input type="text" class="form-control mx-auto" id="{{ modal_id }}Subject" disabled value="{{ title }}"/>
</div>
<textarea autofocus class="form-control modal-textfield my-4" id="{{ modal_id }}MessageText"></textarea>
<div class="modal-submit-group">
<button type="button" class="btn btn-light me-1" data-bs-dismiss="modal">{% trans 'Cancel' %}</button>
<button type="submit" id="{{ modal_id }}ActionButton" class="btn btn-primary ms-1">{% trans 'Send Message' %}</button>
Expand All @@ -43,50 +48,8 @@ <h5 class="modal-title" id="{{ modal_id }}Label">{{ title }}</h5>
</div>
</div>
</div>
<script type="module">
import { ContactModalLogic } from "{% static 'js/contact_modal.js' %}";

<script type="text/javascript">
var {{ modal_id }} = new bootstrap.Modal(document.getElementById('{{ modal_id }}'));
var successMessageModal_{{ modal_id }} = new bootstrap.Modal(document.getElementById('successMessageModal_{{ modal_id }}'));

function {{ modal_id }}Show() {
$('#{{ modal_id }}ActionButton').unbind().click(function(event){ {{ modal_id }}Action(event); });
{{ modal_id }}.show();
}
function {{ modal_id }}Action(event) {
var actionButton = $('#{{ modal_id }}ActionButton');
actionButton.prop('disabled', true);
event.preventDefault();

message = $('#{{ modal_id }}MessageText').val();

if (message.trim() == "") {
{{ modal_id }}.hide();
actionButton.prop('disabled', false);
return;
}

var originalText = actionButton.text();

$.ajax({
url : "/contact",
type : "POST",
data : { message: message, title: "{{ title|escapejs }}" },
success : function(json) {
{{ modal_id }}.hide();
successMessageModal_{{ modal_id }}.show();
$('#{{ modal_id }}MessageText').val("");
setTimeout(function(){
successMessageModal_{{ modal_id }}.hide();
actionButton.prop('disabled', false);
}, 3000);
},
error : function(json) {
actionButton.text('{% trans 'Sending failed, sorry!' %}');
setTimeout(function(){
actionButton.text(originalText);
actionButton.prop('disabled', false);
}, 4000);
}
});
}
new ContactModalLogic("{{ modal_id }}", "{{ title|escapejs }}").attach();
</script>
2 changes: 1 addition & 1 deletion evap/evaluation/templates/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<div class="navbar-nav justify-content-end feedback-button-placeholder">
{% if user.is_authenticated %}
<span class="feedback-button">
<button type="button" class="btn btn-dark" onclick="feedbackModalShow();">
<button id="feedbackModalShowButton" type="button" class="btn btn-dark">
{% trans 'Problems/Feedback' %}
</button>
</span>
Expand Down
30 changes: 26 additions & 4 deletions evap/evaluation/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,38 @@ class TestFAQView(WebTestWith200Check):

class TestContactEmail(WebTest):
csrf_checks = False
url = "/contact"

@override_settings(ALLOW_ANONYMOUS_FEEDBACK_MESSAGES=True)
def test_sends_mail(self):
user = baker.make(UserProfile, email="user@institution.example.com")
# normal email
self.app.post(
"/contact",
params={"message": "feedback message", "title": "some title", "sender_email": "unique@mail.de"},
self.url,
params={"message": "feedback message", "title": "some title", "anonymous": "false"},
user=user,
)
self.assertEqual(len(mail.outbox), 1)
self.assertTrue(mail.outbox[0].reply_to == ["user@institution.example.com"])
# anonymous email
self.app.post(
self.url,
params={"message": "feedback message", "title": "some title", "anonymous": "true"},
user=user,
)

self.assertEqual(len(mail.outbox), 2)
self.assertEqual(mail.outbox[0].reply_to, ["user@institution.example.com"])
self.assertEqual(mail.outbox[1].reply_to, [])

@override_settings(ALLOW_ANONYMOUS_FEEDBACK_MESSAGES=False)
def test_anonymous_not_allowed(self):
user = baker.make(UserProfile, email="user@institution.example.com")
self.app.post(
self.url,
params={"message": "feedback message", "title": "some title", "anonymous": "true"},
user=user,
status=400,
)
self.assertEqual(len(mail.outbox), 0)


class TestChangeLanguageView(WebTest):
Expand Down
17 changes: 12 additions & 5 deletions evap/evaluation/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.conf import settings
from django.contrib import auth, messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import SuspiciousOperation
from django.core.mail import EmailMessage
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect, render
Expand Down Expand Up @@ -161,17 +162,23 @@ def legal_notice(request):
@require_POST
@login_required
def contact(request):
sent_anonymous = request.POST.get("anonymous") == "true"
if sent_anonymous and not settings.ALLOW_ANONYMOUS_FEEDBACK_MESSAGES:
raise SuspiciousOperation("Anonymous feedback messages are not allowed, however received one from user!")
message = request.POST.get("message")
title = request.POST.get("title")
email = request.user.email or f"User {request.user.id}"
subject = f"[EvaP] Message from {email}"

if sent_anonymous:
sender = "anonymous user"
subject = "[EvaP] Anonymous message"
else:
sender = request.user.email or f"User {request.user.id}"
subject = f"[EvaP] Message from {sender}"
if message:
mail = EmailMessage(
subject=subject,
body=f"{title}\n{request.user.email}\n\n{message}",
body=f"{title}\n{sender}\n\n{message}",
to=[settings.CONTACT_EMAIL],
reply_to=[request.user.email],
reply_to=[] if sent_anonymous else [sender],
)
try:
mail.send()
Expand Down
2 changes: 2 additions & 0 deletions evap/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
}

CONTACT_EMAIL = "webmaster@localhost"
ALLOW_ANONYMOUS_FEEDBACK_MESSAGES = True

# Config for mail system
DEFAULT_FROM_EMAIL = "webmaster@localhost"
Expand Down Expand Up @@ -235,6 +236,7 @@
"django.contrib.messages.context_processors.messages",
"evap.context_processors.slogan",
"evap.context_processors.debug",
"evap.context_processors.allow_anonymous_feedback_messages",
],
"builtins": ["django.templatetags.i18n"],
}
Expand Down
22 changes: 22 additions & 0 deletions evap/static/scss/components/_modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@
max-width: 1120px;
}

.modal-grid {
display: grid;
width: 75%;
margin: 1.5rem auto;
grid-template-columns: max-content auto;
grid-row-gap: 0.75rem;
grid-column-gap: 0.25rem;
}

.modal-grid .control-label {
text-align: left;
font-weight: bold;
padding-top: 0.3rem;
margin-bottom: 0.5rem;
margin-left: auto;
}

textarea.modal-textfield {
resize: vertical;
min-height: 150px;
}

.modal-submit-group {
display: flex;
flex-wrap: wrap;
Expand Down
67 changes: 67 additions & 0 deletions evap/static/ts/src/contact_modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
declare const bootstrap: typeof import("bootstrap");

import { selectOrError, sleep, assert } from "./utils.js";
import { CSRF_HEADERS } from "./csrf-utils.js";

const SUCCESS_MESSAGE_TIMEOUT = 3000;

export class ContactModalLogic {
private readonly modal: bootstrap.Modal;
private readonly successMessageModal: bootstrap.Modal;
private readonly actionButtonElement: HTMLButtonElement;
private readonly messageTextElement: HTMLInputElement;
private readonly showButtonElement: HTMLElement;
private readonly title: string;

// may be null if anonymous feedback is not enabled
private readonly anonymousRadioElement: HTMLInputElement | null;

constructor(modalId: string, title: string) {
this.title = title;
this.modal = new bootstrap.Modal(selectOrError("#" + modalId));
this.successMessageModal = new bootstrap.Modal(selectOrError("#successMessageModal_" + modalId));
this.actionButtonElement = selectOrError("#" + modalId + "ActionButton");
this.messageTextElement = selectOrError("#" + modalId + "MessageText");
this.anonymousRadioElement = document.querySelector<HTMLInputElement>("#" + modalId + "AnonymousName");
this.showButtonElement = selectOrError("#" + modalId + "ShowButton");
}

public attach = (): void => {
this.actionButtonElement.addEventListener("click", async event => {
this.actionButtonElement.disabled = true;
event.preventDefault();
const message = this.messageTextElement.value;
if (message.trim() === "") {
this.modal.hide();
this.actionButtonElement.disabled = false;
return;
}
try {
const response = await fetch("/contact", {
body: new URLSearchParams({
anonymous: String(this.anonymousRadioElement !== null && this.anonymousRadioElement.checked),
message,
title: this.title,
}),
headers: CSRF_HEADERS,
method: "POST",
});
assert(response.ok);
} catch (_) {
window.alert("Sending failed, sorry!");
return;
}
this.modal.hide();
this.successMessageModal.show();
this.messageTextElement.value = "";

await sleep(SUCCESS_MESSAGE_TIMEOUT);
this.successMessageModal.hide();
this.actionButtonElement.disabled = false;
});

this.showButtonElement.addEventListener("click", () => {
this.modal.show();
});
};
}
1 change: 1 addition & 0 deletions evap/static/ts/src/csrf-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function getCookie(name: string): string | null {
return null;
}
const csrftoken = getCookie("csrftoken")!;
export const CSRF_HEADERS = { "X-CSRFToken": csrftoken };

function isMethodCsrfSafe(method: string): boolean {
// these HTTP methods do not require CSRF protection
Expand Down
13 changes: 13 additions & 0 deletions evap/static/ts/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const selectOrError = <T extends Element>(selectors: string): T => {
const elem = document.querySelector<T>(selectors);
assert(elem, `Element with id ${selectors} not found`);
return elem;
};

export function assert(condition: unknown, message: string = "Assertion Failed"): asserts condition {
if (!condition) throw new Error(message);
}

export const sleep = (ms: number): Promise<number> => {
return new Promise(resolve => window.setTimeout(resolve, ms));
};
35 changes: 35 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 6aa3a5f

Please sign in to comment.